mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-10 18:47:15 +10:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ad9263cc4 | |||
| 3f780ac667 | |||
| 53caec3e14 | |||
| 3c440ca3d4 | |||
| 8797d8ffde | |||
| faba120823 | |||
| be690c8194 | |||
| 64d75dde45 | |||
| 9199aab7f7 | |||
| 60e8ee0130 | |||
| 6dfb7a4b69 | |||
| 28a423e0a8 | |||
| 3593cfa843 | |||
| dc85e7a41c | |||
| 9265daaf16 | |||
| 4b744184c2 | |||
| 64698e0be6 | |||
| 3dd9037be3 | |||
| 566e3aadf8 | |||
| 06a906f4f7 | |||
| 054a84031e | |||
| fffcff3b74 | |||
| b336aedbc5 | |||
| 2ee2358ecc | |||
| 86e9b7fe01 | |||
| 24fa78741b | |||
| 79a45c527b | |||
| 8b280b37be | |||
| fa4da979af | |||
| 91608ff09e | |||
| 71f59d23df | |||
| e90742be25 | |||
| db935a7454 | |||
| 1ad5db27ca | |||
| 81758adc61 | |||
| c81791cf1e | |||
| 1fba5312a2 | |||
| 2f770bbd53 | |||
| 9db79e9d40 | |||
| 1913a5aa11 | |||
| 929c1c3d28 | |||
| 7a2bb20bf7 | |||
| a1b77bb29b | |||
| 4eecfc92dc | |||
| 90c8cf5f3e | |||
| 06fa176367 | |||
| e4285774a0 | |||
| b2da695102 | |||
| e1327a93c7 | |||
| 421bc71bb7 | |||
| 84ec139ce6 | |||
| b748b96237 | |||
| c2671ac2ae |
@@ -0,0 +1 @@
|
|||||||
|
6.2.4
|
||||||
@@ -51,7 +51,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
|||||||
|
|
||||||
### Device Management
|
### Device Management
|
||||||
|
|
||||||
- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth
|
- **BLE, USB, TCP Connection**: Scan and connect to MeshCore devices via Bluetooth, USB or TCP
|
||||||
- **Device Settings**: Configure radio parameters, power settings, and network options
|
- **Device Settings**: Configure radio parameters, power settings, and network options
|
||||||
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
|
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
|
||||||
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
|
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
|
||||||
@@ -75,10 +75,16 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
|||||||
|
|
||||||
### Platform Support
|
### Platform Support
|
||||||
|
|
||||||
- ✅ **Android**: Full support (API 21+)
|
| Feature | Android (API 21+) | iOS (12+) | Linux | Windows | macOS | Web |
|
||||||
- ✅ **iOS**: Full support (iOS 12+)
|
|--------------------|:-----------------:|:---------:|:-----:|:-------:|:-----:|:---------------------------------:|
|
||||||
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
|
| BLE companion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
- 🚧 **Web**: Under construction (Chrome)
|
| USB companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| TCP companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ❌<br>(requires websocket bridge) |
|
||||||
|
| Core Functionality | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Mesh Network | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Map & Location | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Device Management | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Repeater Hub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
@@ -189,6 +195,7 @@ Messages are transmitted as binary frames using a custom protocol optimized for
|
|||||||
### App Settings
|
### App Settings
|
||||||
|
|
||||||
- **Theme**: System default, light, or dark mode
|
- **Theme**: System default, light, or dark mode
|
||||||
|
- **Language**: Use one of 15 languages (English, Chinese, French, Spanish, Portuguese, German, Dutch, Polish, Swedish, Italian, Slovak, Slovene, Bulgarian, Russian, Ukrainian)
|
||||||
- **Notifications**: Configurable for messages, channels, and node advertisements
|
- **Notifications**: Configurable for messages, channels, and node advertisements
|
||||||
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
|
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
|
||||||
- **Message Retry**: Automatic retry with configurable path clearing
|
- **Message Retry**: Automatic retry with configurable path clearing
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart' as crypto;
|
import 'package:crypto/crypto.dart' as crypto;
|
||||||
import 'package:meshcore_open/models/discovery_contact.dart';
|
|
||||||
import 'package:pointycastle/export.dart';
|
import 'package:pointycastle/export.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
@@ -20,8 +19,10 @@ import '../services/message_retry_service.dart';
|
|||||||
import '../services/path_history_service.dart';
|
import '../services/path_history_service.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
import '../services/background_service.dart';
|
import '../services/background_service.dart';
|
||||||
|
import '../services/timeout_prediction_service.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import 'meshcore_connector_usb.dart';
|
import 'meshcore_connector_usb.dart';
|
||||||
|
import 'meshcore_connector_tcp.dart';
|
||||||
import '../storage/channel_message_store.dart';
|
import '../storage/channel_message_store.dart';
|
||||||
import '../storage/channel_order_store.dart';
|
import '../storage/channel_order_store.dart';
|
||||||
import '../storage/channel_settings_store.dart';
|
import '../storage/channel_settings_store.dart';
|
||||||
@@ -86,7 +87,7 @@ enum MeshCoreConnectionState {
|
|||||||
disconnecting,
|
disconnecting,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MeshCoreTransportType { bluetooth, usb }
|
enum MeshCoreTransportType { bluetooth, usb, tcp }
|
||||||
|
|
||||||
class RepeaterBatterySnapshot {
|
class RepeaterBatterySnapshot {
|
||||||
final int millivolts;
|
final int millivolts;
|
||||||
@@ -116,11 +117,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
bool _manualDisconnect = false;
|
bool _manualDisconnect = false;
|
||||||
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
|
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
|
||||||
StreamSubscription<Uint8List>? _usbFrameSubscription;
|
StreamSubscription<Uint8List>? _usbFrameSubscription;
|
||||||
|
final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector();
|
||||||
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
|
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
|
||||||
|
|
||||||
final List<ScanResult> _scanResults = [];
|
final List<ScanResult> _scanResults = [];
|
||||||
final List<Contact> _contacts = [];
|
final List<Contact> _contacts = [];
|
||||||
final List<DiscoveryContact> _discoveredContacts = [];
|
final List<Contact> _discoveredContacts = [];
|
||||||
final List<Channel> _channels = [];
|
final List<Channel> _channels = [];
|
||||||
final Map<String, List<Message>> _conversations = {};
|
final Map<String, List<Message>> _conversations = {};
|
||||||
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
||||||
@@ -165,6 +167,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
bool _isLoadingContacts = false;
|
bool _isLoadingContacts = false;
|
||||||
bool _isLoadingChannels = false;
|
bool _isLoadingChannels = false;
|
||||||
bool _hasLoadedChannels = false;
|
bool _hasLoadedChannels = false;
|
||||||
|
TimeoutPredictionService? _timeoutPredictionService;
|
||||||
|
// Intentionally global (not per-contact): tracks overall network activity.
|
||||||
|
// Frequent RX from any source indicates a busy network with more collisions.
|
||||||
|
DateTime _lastRxTime = DateTime.now();
|
||||||
bool _batteryRequested = false;
|
bool _batteryRequested = false;
|
||||||
bool _awaitingSelfInfo = false;
|
bool _awaitingSelfInfo = false;
|
||||||
bool _hasReceivedDeviceInfo = false;
|
bool _hasReceivedDeviceInfo = false;
|
||||||
@@ -198,6 +204,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
int _queueSyncRetries = 0;
|
int _queueSyncRetries = 0;
|
||||||
static const int _maxQueueSyncRetries = 3;
|
static const int _maxQueueSyncRetries = 3;
|
||||||
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
||||||
|
// Serializes path operations (setContactPath/clearContactPath) to prevent
|
||||||
|
// interleaved async calls from leaving in-memory state inconsistent with device.
|
||||||
|
Future<void> _pathOpLock = Future.value();
|
||||||
Map<String, String>? _currentCustomVars;
|
Map<String, String>? _currentCustomVars;
|
||||||
|
|
||||||
// Channel syncing state (sequential pattern)
|
// Channel syncing state (sequential pattern)
|
||||||
@@ -255,6 +264,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
bool get isUsbTransportConnected =>
|
bool get isUsbTransportConnected =>
|
||||||
_state == MeshCoreConnectionState.connected &&
|
_state == MeshCoreConnectionState.connected &&
|
||||||
_activeTransport == MeshCoreTransportType.usb;
|
_activeTransport == MeshCoreTransportType.usb;
|
||||||
|
String? get activeTcpEndpoint => _tcpConnector.activeEndpoint;
|
||||||
|
bool get isTcpTransportConnected =>
|
||||||
|
_state == MeshCoreConnectionState.connected &&
|
||||||
|
_activeTransport == MeshCoreTransportType.tcp;
|
||||||
|
|
||||||
String get deviceDisplayName {
|
String get deviceDisplayName {
|
||||||
if (_selfName != null && _selfName!.isNotEmpty) {
|
if (_selfName != null && _selfName!.isNotEmpty) {
|
||||||
@@ -281,7 +294,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<DiscoveryContact> get discoveredContacts {
|
List<Contact> get allContacts => List.unmodifiable([
|
||||||
|
..._contacts,
|
||||||
|
..._discoveredContacts.where((c) => !c.isActive),
|
||||||
|
]);
|
||||||
|
List<Contact> get discoveredContacts {
|
||||||
return List.unmodifiable(_discoveredContacts);
|
return List.unmodifiable(_discoveredContacts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +308,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
bool get isLoadingChannels => _isLoadingChannels;
|
bool get isLoadingChannels => _isLoadingChannels;
|
||||||
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
|
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
|
||||||
Uint8List? get selfPublicKey => _selfPublicKey;
|
Uint8List? get selfPublicKey => _selfPublicKey;
|
||||||
|
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
|
||||||
String? get selfName => _selfName;
|
String? get selfName => _selfName;
|
||||||
double? get selfLatitude => _selfLatitude;
|
double? get selfLatitude => _selfLatitude;
|
||||||
double? get selfLongitude => _selfLongitude;
|
double? get selfLongitude => _selfLongitude;
|
||||||
@@ -306,6 +324,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
bool? get autoAddRoomServers => _autoAddRoomServers;
|
bool? get autoAddRoomServers => _autoAddRoomServers;
|
||||||
bool? get autoAddSensors => _autoAddSensors;
|
bool? get autoAddSensors => _autoAddSensors;
|
||||||
bool? get autoAddOverwriteOldest => _overwriteOldest;
|
bool? get autoAddOverwriteOldest => _overwriteOldest;
|
||||||
|
int get telemetryModeBase => _telemetryModeBase;
|
||||||
|
int get telemetryModeLoc => _telemetryModeLoc;
|
||||||
|
int get telemetryModeEnv => _telemetryModeEnv;
|
||||||
|
int get advertLocationPolicy => _advertLocPolicy;
|
||||||
|
int get multiAcks => _multiAcks;
|
||||||
bool? get clientRepeat => _clientRepeat;
|
bool? get clientRepeat => _clientRepeat;
|
||||||
int? get firmwareVerCode => _firmwareVerCode;
|
int? get firmwareVerCode => _firmwareVerCode;
|
||||||
Map<String, String>? get currentCustomVars => _currentCustomVars;
|
Map<String, String>? get currentCustomVars => _currentCustomVars;
|
||||||
@@ -552,6 +575,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_unreadStore.saveContactUnreadCount(
|
_unreadStore.saveContactUnreadCount(
|
||||||
Map<String, int>.from(_contactUnreadCount),
|
Map<String, int>.from(_contactUnreadCount),
|
||||||
);
|
);
|
||||||
|
_notificationService.clearContactNotification(
|
||||||
|
contactKeyHex,
|
||||||
|
getTotalUnreadCount(),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,6 +597,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_channels.isNotEmpty ? _channels : _cachedChannels,
|
_channels.isNotEmpty ? _channels : _cachedChannels,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
_notificationService.clearChannelNotification(
|
||||||
|
channelIndex,
|
||||||
|
getTotalUnreadCount(),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,6 +682,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
BleDebugLogService? bleDebugLogService,
|
BleDebugLogService? bleDebugLogService,
|
||||||
AppDebugLogService? appDebugLogService,
|
AppDebugLogService? appDebugLogService,
|
||||||
BackgroundService? backgroundService,
|
BackgroundService? backgroundService,
|
||||||
|
TimeoutPredictionService? timeoutPredictionService,
|
||||||
}) {
|
}) {
|
||||||
_retryService = retryService;
|
_retryService = retryService;
|
||||||
_pathHistoryService = pathHistoryService;
|
_pathHistoryService = pathHistoryService;
|
||||||
@@ -658,7 +690,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_bleDebugLogService = bleDebugLogService;
|
_bleDebugLogService = bleDebugLogService;
|
||||||
_appDebugLogService = appDebugLogService;
|
_appDebugLogService = appDebugLogService;
|
||||||
_backgroundService = backgroundService;
|
_backgroundService = backgroundService;
|
||||||
|
_timeoutPredictionService = timeoutPredictionService;
|
||||||
_usbManager.setDebugLogService(_appDebugLogService);
|
_usbManager.setDebugLogService(_appDebugLogService);
|
||||||
|
_tcpConnector.setDebugLogService(_appDebugLogService);
|
||||||
|
|
||||||
// Initialize notification service
|
// Initialize notification service
|
||||||
_notificationService.initialize();
|
_notificationService.initialize();
|
||||||
@@ -671,13 +705,28 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
updateMessageCallback: _updateMessage,
|
updateMessageCallback: _updateMessage,
|
||||||
clearContactPathCallback: clearContactPath,
|
clearContactPathCallback: clearContactPath,
|
||||||
setContactPathCallback: setContactPath,
|
setContactPathCallback: setContactPath,
|
||||||
calculateTimeoutCallback: (pathLength, messageBytes) =>
|
calculateTimeoutCallback:
|
||||||
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
|
(pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
contactKey: contactKey,
|
||||||
|
),
|
||||||
getSelfPublicKeyCallback: () => _selfPublicKey,
|
getSelfPublicKeyCallback: () => _selfPublicKey,
|
||||||
prepareContactOutboundTextCallback: prepareContactOutboundText,
|
prepareContactOutboundTextCallback: prepareContactOutboundText,
|
||||||
appSettingsService: appSettingsService,
|
appSettingsService: appSettingsService,
|
||||||
debugLogService: _appDebugLogService,
|
debugLogService: _appDebugLogService,
|
||||||
recordPathResultCallback: _recordPathResult,
|
recordPathResultCallback: _recordPathResult,
|
||||||
|
onDeliveryObservedCallback:
|
||||||
|
(contactKey, pathLength, messageBytes, tripTimeMs) {
|
||||||
|
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||||
|
_timeoutPredictionService?.recordObservation(
|
||||||
|
contactKey: contactKey,
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
tripTimeMs: tripTimeMs,
|
||||||
|
secondsSinceLastRx: secSinceRx,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,12 +735,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_knownContactKeys
|
_knownContactKeys
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(cached.map((c) => c.publicKeyHex));
|
..addAll(cached.map((c) => c.publicKeyHex));
|
||||||
|
_contacts
|
||||||
|
..clear()
|
||||||
|
..addAll(cached);
|
||||||
for (final contact in cached) {
|
for (final contact in cached) {
|
||||||
_ensureContactSmazSettingLoaded(contact.publicKeyHex);
|
_ensureContactSmazSettingLoaded(contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadDiscoveredContactCache() async {
|
Future<void> _loadDiscoveredContactCache() async {
|
||||||
final cached = await _discoveryContactStore.loadContacts();
|
final cached = await _discoveryContactStore.loadContacts();
|
||||||
_discoveredContacts
|
_discoveredContacts
|
||||||
..clear()
|
..clear()
|
||||||
@@ -906,10 +958,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await _usbFrameSubscription?.cancel();
|
await _usbFrameSubscription?.cancel();
|
||||||
_usbFrameSubscription = null;
|
_usbFrameSubscription = null;
|
||||||
_appDebugLogService?.info(
|
_appDebugLogService?.info('connectUsb: opening serial port…', tag: 'USB');
|
||||||
'connectUsb: opening serial port…',
|
|
||||||
tag: 'USB',
|
|
||||||
);
|
|
||||||
await _usbManager.connect(portName: portName, baudRate: baudRate);
|
await _usbManager.connect(portName: portName, baudRate: baudRate);
|
||||||
_appDebugLogService?.info(
|
_appDebugLogService?.info(
|
||||||
'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
|
'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
|
||||||
@@ -967,6 +1016,142 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> connectTcp({required String host, required int port}) async {
|
||||||
|
if (_state == MeshCoreConnectionState.connecting ||
|
||||||
|
_state == MeshCoreConnectionState.connected) {
|
||||||
|
_appDebugLogService?.warn(
|
||||||
|
'connectTcp ignored: already $_state',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_appDebugLogService?.info('connectTcp: endpoint=$host:$port', tag: 'TCP');
|
||||||
|
|
||||||
|
await stopScan();
|
||||||
|
_cancelReconnectTimer();
|
||||||
|
_manualDisconnect = false;
|
||||||
|
_resetConnectionHandshakeState();
|
||||||
|
_activeTransport = MeshCoreTransportType.tcp;
|
||||||
|
_setState(MeshCoreConnectionState.connecting);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Future<void> handleTcpConnectAbort({required String message}) async {
|
||||||
|
_appDebugLogService?.warn(message, tag: 'TCP');
|
||||||
|
final shouldResetState = shouldResetStateAfterTcpConnectAbort(
|
||||||
|
state: _state,
|
||||||
|
activeTransport: _activeTransport,
|
||||||
|
);
|
||||||
|
if (shouldResetState) {
|
||||||
|
await disconnect(manual: false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_tcpConnector.isConnected) {
|
||||||
|
await _tcpConnector.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _tcpConnector.cancelFrameSubscription();
|
||||||
|
await _tcpConnector.connect(host: host, port: port);
|
||||||
|
final isTcpConnectCancelled =
|
||||||
|
_activeTransport != MeshCoreTransportType.tcp ||
|
||||||
|
_state != MeshCoreConnectionState.connecting ||
|
||||||
|
!_tcpConnector.isConnected;
|
||||||
|
if (isTcpConnectCancelled) {
|
||||||
|
await handleTcpConnectAbort(
|
||||||
|
message:
|
||||||
|
'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
final isTcpConnectCancelledAfterDelay =
|
||||||
|
_activeTransport != MeshCoreTransportType.tcp ||
|
||||||
|
_state != MeshCoreConnectionState.connecting ||
|
||||||
|
!_tcpConnector.isConnected;
|
||||||
|
if (isTcpConnectCancelledAfterDelay) {
|
||||||
|
await handleTcpConnectAbort(
|
||||||
|
message:
|
||||||
|
'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_tcpConnector.listenFrames(
|
||||||
|
onFrame: _handleFrame,
|
||||||
|
onError: (error, stackTrace) {
|
||||||
|
_appDebugLogService?.error('TCP transport error: $error', tag: 'TCP');
|
||||||
|
unawaited(disconnect(manual: false));
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
_appDebugLogService?.warn('TCP frame stream ended', tag: 'TCP');
|
||||||
|
unawaited(disconnect(manual: false));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_setState(MeshCoreConnectionState.connected);
|
||||||
|
_pendingInitialChannelSync = true;
|
||||||
|
await _requestDeviceInfo();
|
||||||
|
_startBatteryPolling();
|
||||||
|
|
||||||
|
var gotSelfInfo = await _waitForSelfInfo(
|
||||||
|
timeout: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
if (!gotSelfInfo) {
|
||||||
|
await refreshDeviceInfo();
|
||||||
|
gotSelfInfo = await _waitForSelfInfo(
|
||||||
|
timeout: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!gotSelfInfo) {
|
||||||
|
throw StateError('Timed out waiting for SELF_INFO during TCP connect');
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncTime();
|
||||||
|
} catch (error) {
|
||||||
|
_appDebugLogService?.error('TCP connection error: $error', tag: 'TCP');
|
||||||
|
final tcpConnectCancelledBeforeHandshake =
|
||||||
|
shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: _manualDisconnect,
|
||||||
|
state: _state,
|
||||||
|
activeTransport: _activeTransport,
|
||||||
|
tcpManagerConnected: _tcpConnector.isConnected,
|
||||||
|
);
|
||||||
|
if (tcpConnectCancelledBeforeHandshake) {
|
||||||
|
_appDebugLogService?.info(
|
||||||
|
'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await disconnect(manual: false);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static bool shouldIgnoreLateTcpConnectError({
|
||||||
|
required bool manualDisconnect,
|
||||||
|
required MeshCoreConnectionState state,
|
||||||
|
required MeshCoreTransportType activeTransport,
|
||||||
|
required bool tcpManagerConnected,
|
||||||
|
}) {
|
||||||
|
return manualDisconnect &&
|
||||||
|
(state == MeshCoreConnectionState.disconnected ||
|
||||||
|
state == MeshCoreConnectionState.disconnecting) &&
|
||||||
|
(activeTransport != MeshCoreTransportType.tcp || !tcpManagerConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static bool shouldResetStateAfterTcpConnectAbort({
|
||||||
|
required MeshCoreConnectionState state,
|
||||||
|
required MeshCoreTransportType activeTransport,
|
||||||
|
}) {
|
||||||
|
return state == MeshCoreConnectionState.connecting &&
|
||||||
|
activeTransport == MeshCoreTransportType.tcp;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> connect(BluetoothDevice device, {String? displayName}) async {
|
Future<void> connect(BluetoothDevice device, {String? displayName}) async {
|
||||||
if (_state == MeshCoreConnectionState.connecting ||
|
if (_state == MeshCoreConnectionState.connecting ||
|
||||||
_state == MeshCoreConnectionState.connected) {
|
_state == MeshCoreConnectionState.connected) {
|
||||||
@@ -1196,7 +1381,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
await _requestDeviceInfo();
|
await _requestDeviceInfo();
|
||||||
_startBatteryPolling();
|
_startBatteryPolling();
|
||||||
unawaited(loadDiscoveredContactCache());
|
|
||||||
|
|
||||||
final gotSelfInfo = await _waitForSelfInfo(
|
final gotSelfInfo = await _waitForSelfInfo(
|
||||||
timeout: const Duration(seconds: 3),
|
timeout: const Duration(seconds: 3),
|
||||||
@@ -1233,6 +1417,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
bool get _shouldGateInitialChannelSync =>
|
bool get _shouldGateInitialChannelSync =>
|
||||||
_activeTransport == MeshCoreTransportType.usb ||
|
_activeTransport == MeshCoreTransportType.usb ||
|
||||||
|
_activeTransport == MeshCoreTransportType.tcp ||
|
||||||
(_activeTransport == MeshCoreTransportType.bluetooth &&
|
(_activeTransport == MeshCoreTransportType.bluetooth &&
|
||||||
PlatformInfo.isWeb);
|
PlatformInfo.isWeb);
|
||||||
|
|
||||||
@@ -1279,9 +1464,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
Future<void> disconnect({bool manual = true}) async {
|
Future<void> disconnect({bool manual = true}) async {
|
||||||
if (_state == MeshCoreConnectionState.disconnecting) return;
|
if (_state == MeshCoreConnectionState.disconnecting) return;
|
||||||
final transportAtDisconnect = _activeTransport;
|
final transportAtDisconnect = _activeTransport;
|
||||||
final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb
|
final transportLabel = switch (transportAtDisconnect) {
|
||||||
? 'USB'
|
MeshCoreTransportType.bluetooth => 'BLE',
|
||||||
: 'BLE';
|
MeshCoreTransportType.usb => 'USB',
|
||||||
|
MeshCoreTransportType.tcp => 'TCP',
|
||||||
|
};
|
||||||
|
|
||||||
_appDebugLogService?.info(
|
_appDebugLogService?.info(
|
||||||
'Starting disconnect transport=$transportLabel manual=$manual',
|
'Starting disconnect transport=$transportLabel manual=$manual',
|
||||||
@@ -1301,6 +1488,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
await _usbFrameSubscription?.cancel();
|
await _usbFrameSubscription?.cancel();
|
||||||
_usbFrameSubscription = null;
|
_usbFrameSubscription = null;
|
||||||
await _usbManager.disconnect();
|
await _usbManager.disconnect();
|
||||||
|
await _tcpConnector.disconnect();
|
||||||
|
|
||||||
await _notifySubscription?.cancel();
|
await _notifySubscription?.cancel();
|
||||||
_notifySubscription = null;
|
_notifySubscription = null;
|
||||||
@@ -1382,6 +1570,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
if (_activeTransport == MeshCoreTransportType.usb) {
|
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||||
await _usbManager.write(data);
|
await _usbManager.write(data);
|
||||||
|
// Brief pause so the device firmware can process each frame before the
|
||||||
|
// next arrives. Without this, rapid-fire frames over USB can cause the
|
||||||
|
// device to miss responses (especially on reconnect).
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
} else if (_activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
await _tcpConnector.write(data);
|
||||||
} else {
|
} else {
|
||||||
if (_rxCharacteristic == null) {
|
if (_rxCharacteristic == null) {
|
||||||
throw Exception("MeshCore RX characteristic not available");
|
throw Exception("MeshCore RX characteristic not available");
|
||||||
@@ -1595,6 +1789,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
Uint8List customPath,
|
Uint8List customPath,
|
||||||
int pathLen,
|
int pathLen,
|
||||||
) async {
|
) async {
|
||||||
|
// Serialize path operations to prevent interleaved async calls from
|
||||||
|
// leaving in-memory state inconsistent with the device.
|
||||||
|
final prev = _pathOpLock;
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_pathOpLock = completer.future;
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
await sendFrame(
|
await sendFrame(
|
||||||
@@ -1607,15 +1808,46 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
name: contact.name,
|
name: contact.name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// USB writes return instantly (no BLE flow control), so give the firmware
|
||||||
|
// time to persist the path change before subsequent commands.
|
||||||
|
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
|
Future<void> setContactFlags(
|
||||||
|
Contact contact, {
|
||||||
|
bool? isFavorite,
|
||||||
|
bool? teleBase,
|
||||||
|
bool? teleLoc,
|
||||||
|
bool? teleEnv,
|
||||||
|
}) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
final latestContact =
|
final latestContact =
|
||||||
await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact;
|
await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact;
|
||||||
final updatedFlags = isFavorite
|
int updatedFlags = isFavorite != null
|
||||||
|
? (isFavorite
|
||||||
? (latestContact.flags | contactFlagFavorite)
|
? (latestContact.flags | contactFlagFavorite)
|
||||||
: (latestContact.flags & ~contactFlagFavorite);
|
: (latestContact.flags & ~contactFlagFavorite))
|
||||||
|
: latestContact.flags;
|
||||||
|
updatedFlags = teleBase != null
|
||||||
|
? (teleBase
|
||||||
|
? (updatedFlags | contactFlagTeleBase)
|
||||||
|
: (updatedFlags & ~contactFlagTeleBase))
|
||||||
|
: updatedFlags;
|
||||||
|
updatedFlags = teleLoc != null
|
||||||
|
? (teleLoc
|
||||||
|
? (updatedFlags | contactFlagTeleLoc)
|
||||||
|
: (updatedFlags & ~contactFlagTeleLoc))
|
||||||
|
: updatedFlags;
|
||||||
|
updatedFlags = teleEnv != null
|
||||||
|
? (teleEnv
|
||||||
|
? (updatedFlags | contactFlagTeleEnv)
|
||||||
|
: (updatedFlags & ~contactFlagTeleEnv))
|
||||||
|
: updatedFlags;
|
||||||
|
|
||||||
await sendFrame(
|
await sendFrame(
|
||||||
buildUpdateContactPathFrame(
|
buildUpdateContactPathFrame(
|
||||||
@@ -1906,7 +2138,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
Future<void> removeContact(Contact contact) async {
|
Future<void> removeContact(Contact contact) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
_handleDiscovery(contact, Uint8List(0), noNotify: true);
|
_handleDiscovery(
|
||||||
|
contact,
|
||||||
|
contact.rawPacket ?? Uint8List(0),
|
||||||
|
noNotify: true,
|
||||||
|
);
|
||||||
|
|
||||||
await sendFrame(buildRemoveContactFrame(contact.publicKey));
|
await sendFrame(buildRemoveContactFrame(contact.publicKey));
|
||||||
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
||||||
@@ -1922,7 +2158,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeDiscoveredContact(DiscoveryContact contact) async {
|
Future<void> updateKnownDiscovered() async {
|
||||||
|
if (!isConnected) return;
|
||||||
|
for (int i = 0; i < _discoveredContacts.length; i++) {
|
||||||
|
_discoveredContacts[i] = _discoveredContacts[i].copyWith(
|
||||||
|
isActive: _knownContactKeys.contains(
|
||||||
|
_discoveredContacts[i].publicKeyHex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unawaited(_persistDiscoveredContacts());
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeDiscoveredContact(Contact contact) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
_discoveredContacts.removeWhere(
|
_discoveredContacts.removeWhere(
|
||||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||||
@@ -1931,7 +2180,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> importDiscoveredContact(DiscoveryContact contact) async {
|
Future<void> importDiscoveredContact(Contact contact) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
await sendFrame(
|
await sendFrame(
|
||||||
@@ -1940,11 +2189,23 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
contact.path,
|
contact.path,
|
||||||
contact.pathLength,
|
contact.pathLength,
|
||||||
type: contact.type,
|
type: contact.type,
|
||||||
flags: 0,
|
flags: contact.flags,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
|
lat: contact.latitude,
|
||||||
|
lon: contact.longitude,
|
||||||
|
lastModified: contact.lastSeen,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update the discovered contact to mark it as active (imported)
|
||||||
|
final discoveredIndex = _discoveredContacts.indexWhere(
|
||||||
|
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||||
|
);
|
||||||
|
if (discoveredIndex >= 0) {
|
||||||
|
_discoveredContacts[discoveredIndex] =
|
||||||
|
_discoveredContacts[discoveredIndex].copyWith(isActive: true);
|
||||||
|
}
|
||||||
|
|
||||||
_handleContactAdvert(
|
_handleContactAdvert(
|
||||||
Contact(
|
Contact(
|
||||||
publicKey: contact.publicKey,
|
publicKey: contact.publicKey,
|
||||||
@@ -1952,22 +2213,34 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
type: contact.type,
|
type: contact.type,
|
||||||
pathLength: contact.pathLength,
|
pathLength: contact.pathLength,
|
||||||
path: contact.path,
|
path: contact.path,
|
||||||
|
latitude: contact.latitude,
|
||||||
|
longitude: contact.longitude,
|
||||||
lastSeen: DateTime.now(),
|
lastSeen: DateTime.now(),
|
||||||
|
flags: contact.flags,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearContactPath(Contact contact) async {
|
Future<void> clearContactPath(Contact contact) async {
|
||||||
|
// Serialize path operations to prevent interleaved async calls.
|
||||||
|
final prev = _pathOpLock;
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_pathOpLock = completer.future;
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
await sendFrame(buildResetPathFrame(contact.publicKey));
|
await sendFrame(buildResetPathFrame(contact.publicKey));
|
||||||
|
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
final existingIndex = _contacts.indexWhere(
|
final existingIndex = _contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||||
);
|
);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
final existing = _contacts[existingIndex];
|
final existing = _contacts[existingIndex];
|
||||||
// Use copyWith to preserve pathOverride and pathOverrideBytes
|
// Preserve pathOverride and pathOverrideBytes — only reset device path
|
||||||
_contacts[existingIndex] = existing.copyWith(
|
_contacts[existingIndex] = existing.copyWith(
|
||||||
pathLength: -1,
|
pathLength: -1,
|
||||||
path: Uint8List(0),
|
path: Uint8List(0),
|
||||||
@@ -1975,7 +2248,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
unawaited(_persistContacts());
|
unawaited(_persistContacts());
|
||||||
}
|
}
|
||||||
// The device will send updated contact info with path_len = -1
|
} finally {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateContactInMemory(
|
void updateContactInMemory(
|
||||||
@@ -2109,6 +2384,31 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
|
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setTelemetryModeBase(
|
||||||
|
int base,
|
||||||
|
int location,
|
||||||
|
int env,
|
||||||
|
int advert,
|
||||||
|
int multiAcks,
|
||||||
|
) async {
|
||||||
|
if (!isConnected) return;
|
||||||
|
_telemetryModeBase = base.clamp(teleModeDeny, teleModeAllowAll).toInt();
|
||||||
|
_telemetryModeLoc = location.clamp(teleModeDeny, teleModeAllowAll).toInt();
|
||||||
|
_telemetryModeEnv = env.clamp(teleModeDeny, teleModeAllowAll).toInt();
|
||||||
|
_advertLocPolicy = advert.clamp(0, 1).toInt();
|
||||||
|
_multiAcks = multiAcks.clamp(0, 2).toInt();
|
||||||
|
await sendFrame(
|
||||||
|
buildSetOtherParamsFrame(
|
||||||
|
(_telemetryModeEnv << 4) |
|
||||||
|
(_telemetryModeLoc << 2) |
|
||||||
|
_telemetryModeBase,
|
||||||
|
_advertLocPolicy,
|
||||||
|
_multiAcks,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> getChannels({int? maxChannels, bool force = false}) async {
|
Future<void> getChannels({int? maxChannels, bool force = false}) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
if (_isSyncingChannels) {
|
if (_isSyncingChannels) {
|
||||||
@@ -2250,6 +2550,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_hasLoadedChannels = true;
|
_hasLoadedChannels = true;
|
||||||
_previousChannelsCache.clear();
|
_previousChannelsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: if contact sync was deferred waiting for channel 0 but
|
||||||
|
// channel sync finished without triggering it, start contacts now.
|
||||||
|
if (_pendingInitialContactsSync && isConnected) {
|
||||||
|
_pendingInitialContactsSync = false;
|
||||||
|
unawaited(getContacts());
|
||||||
|
}
|
||||||
|
|
||||||
// Keep cache on failure/disconnection for future attempts
|
// Keep cache on failure/disconnection for future attempts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2276,6 +2584,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
void _handleFrame(List<int> data) {
|
void _handleFrame(List<int> data) {
|
||||||
if (data.isEmpty) return;
|
if (data.isEmpty) return;
|
||||||
|
_lastRxTime = DateTime.now();
|
||||||
|
|
||||||
final frame = Uint8List.fromList(data);
|
final frame = Uint8List.fromList(data);
|
||||||
_receivedFramesController.add(frame);
|
_receivedFramesController.add(frame);
|
||||||
@@ -2303,10 +2612,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_isLoadingContacts = true;
|
_isLoadingContacts = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
break;
|
break;
|
||||||
|
case pushCodeAdvert:
|
||||||
|
// Known contact was seen again - just a pub key, no action needed
|
||||||
|
break;
|
||||||
case pushCodeNewAdvert:
|
case pushCodeNewAdvert:
|
||||||
debugPrint('Got New CONTACT');
|
debugPrint('Got New CONTACT');
|
||||||
// It's the same format as respCodeContact, so we can reuse the handler
|
// It's the same format as respCodeContact, so we can reuse the handler
|
||||||
_handleContact(frame);
|
_handleContact(frame, isContact: false);
|
||||||
break;
|
break;
|
||||||
case respCodeContact:
|
case respCodeContact:
|
||||||
debugPrint('Got CONTACT');
|
debugPrint('Got CONTACT');
|
||||||
@@ -2316,6 +2628,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
debugPrint('Got END_OF_CONTACTS');
|
debugPrint('Got END_OF_CONTACTS');
|
||||||
_isLoadingContacts = false;
|
_isLoadingContacts = false;
|
||||||
_preserveContactsOnRefresh = false;
|
_preserveContactsOnRefresh = false;
|
||||||
|
unawaited(updateKnownDiscovered());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
unawaited(_persistContacts());
|
unawaited(_persistContacts());
|
||||||
if (PlatformInfo.isWeb &&
|
if (PlatformInfo.isWeb &&
|
||||||
@@ -2331,7 +2644,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
if (_pendingDeferredChannelSyncAfterContacts &&
|
if (_pendingDeferredChannelSyncAfterContacts &&
|
||||||
(_activeTransport == MeshCoreTransportType.bluetooth ||
|
(_activeTransport == MeshCoreTransportType.bluetooth ||
|
||||||
_activeTransport == MeshCoreTransportType.usb)) {
|
_activeTransport == MeshCoreTransportType.usb ||
|
||||||
|
_activeTransport == MeshCoreTransportType.tcp)) {
|
||||||
_pendingDeferredChannelSyncAfterContacts = false;
|
_pendingDeferredChannelSyncAfterContacts = false;
|
||||||
_pendingInitialChannelSync = false;
|
_pendingInitialChannelSync = false;
|
||||||
unawaited(getChannels());
|
unawaited(getChannels());
|
||||||
@@ -2482,6 +2796,28 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
selfName.isNotEmpty) {
|
selfName.isNotEmpty) {
|
||||||
_usbManager.updateConnectedLabel(selfName);
|
_usbManager.updateConnectedLabel(selfName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//set all the stores' public key so they can load the correct data
|
||||||
|
_channelMessageStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
|
_messageStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
|
_channelOrderStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
|
_channelSettingsStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
|
_contactSettingsStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
|
_contactStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
|
_channelStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
|
_unreadStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
|
|
||||||
|
// Now that we have self info, we can load all the persisted data for this node
|
||||||
|
_loadChannelOrder();
|
||||||
|
loadContactCache();
|
||||||
|
loadChannelSettings();
|
||||||
|
loadCachedChannels();
|
||||||
|
|
||||||
|
// Load persisted channel messages
|
||||||
|
loadAllChannelMessages();
|
||||||
|
loadUnreadState();
|
||||||
|
_loadDiscoveredContactCache();
|
||||||
|
|
||||||
_awaitingSelfInfo = false;
|
_awaitingSelfInfo = false;
|
||||||
_selfInfoRetryTimer?.cancel();
|
_selfInfoRetryTimer?.cancel();
|
||||||
_selfInfoRetryTimer = null;
|
_selfInfoRetryTimer = null;
|
||||||
@@ -2498,14 +2834,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
if (PlatformInfo.isWeb &&
|
if (PlatformInfo.isWeb &&
|
||||||
_activeTransport == MeshCoreTransportType.bluetooth) {
|
_activeTransport == MeshCoreTransportType.bluetooth) {
|
||||||
_pendingInitialContactsSync = true;
|
_pendingInitialContactsSync = true;
|
||||||
} else if (_activeTransport == MeshCoreTransportType.usb) {
|
} else if (_activeTransport == MeshCoreTransportType.usb ||
|
||||||
|
_activeTransport == MeshCoreTransportType.tcp) {
|
||||||
_pendingDeferredChannelSyncAfterContacts = true;
|
_pendingDeferredChannelSyncAfterContacts = true;
|
||||||
getContacts();
|
getContacts();
|
||||||
} else {
|
} else {
|
||||||
getContacts();
|
getContacts();
|
||||||
}
|
}
|
||||||
if (_shouldGateInitialChannelSync &&
|
if (_shouldGateInitialChannelSync &&
|
||||||
_activeTransport != MeshCoreTransportType.usb) {
|
_activeTransport != MeshCoreTransportType.usb &&
|
||||||
|
_activeTransport != MeshCoreTransportType.tcp) {
|
||||||
_maybeStartInitialChannelSync();
|
_maybeStartInitialChannelSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2623,41 +2961,73 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate timeout for a message based on radio settings and path length
|
/// Estimate single-packet airtime in ms from radio settings, or a fallback.
|
||||||
/// Returns timeout in milliseconds, considering number of hops
|
int _estimateAirtimeMs(int messageBytes) {
|
||||||
int calculateTimeout({required int pathLength, int messageBytes = 100}) {
|
|
||||||
// If we have radio settings, use them for accurate calculation
|
|
||||||
if (_currentFreqHz != null &&
|
if (_currentFreqHz != null &&
|
||||||
_currentBwHz != null &&
|
_currentBwHz != null &&
|
||||||
_currentSf != null &&
|
_currentSf != null &&
|
||||||
_currentCr != null) {
|
_currentCr != null) {
|
||||||
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
|
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
|
||||||
return calculateMessageTimeout(
|
return calculateLoRaAirtime(
|
||||||
freqHz: _currentFreqHz!,
|
payloadBytes: messageBytes,
|
||||||
bwHz: _currentBwHz!,
|
spreadingFactor: _currentSf!,
|
||||||
sf: _currentSf!,
|
bandwidthHz: _currentBwHz!,
|
||||||
cr: cr,
|
codingRate: cr,
|
||||||
pathLength: pathLength,
|
lowDataRateOptimize: _currentSf! >= 11,
|
||||||
messageBytes: messageBytes,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return 50; // fallback: ~SF7/BW125 for 100 bytes
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: Conservative estimates based on typical settings
|
/// Physics-based worst-case timeout (ceiling).
|
||||||
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes
|
int _physicsMaxTimeout(int pathLength, int airtime) {
|
||||||
const estimatedAirtime = 50;
|
|
||||||
|
|
||||||
if (pathLength < 0) {
|
if (pathLength < 0) {
|
||||||
// Flood mode: Base delay + 16× airtime
|
return 500 + (16 * airtime);
|
||||||
return 500 + (16 * estimatedAirtime);
|
|
||||||
} else {
|
} else {
|
||||||
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1))
|
return 500 + ((airtime * 6 + 250) * (pathLength + 1));
|
||||||
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleContact(Uint8List frame) {
|
/// Physics-based minimum timeout (floor): raw traversal time.
|
||||||
|
int _physicsMinTimeout(int pathLength, int airtime) {
|
||||||
|
if (pathLength < 0) {
|
||||||
|
return airtime;
|
||||||
|
} else {
|
||||||
|
return airtime * (pathLength + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate timeout for a message based on radio settings and path length.
|
||||||
|
/// Returns timeout in milliseconds, considering number of hops.
|
||||||
|
int calculateTimeout({
|
||||||
|
required int pathLength,
|
||||||
|
int messageBytes = 100,
|
||||||
|
String? contactKey,
|
||||||
|
}) {
|
||||||
|
final airtime = _estimateAirtimeMs(messageBytes);
|
||||||
|
final physicsMin = _physicsMinTimeout(pathLength, airtime);
|
||||||
|
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
|
||||||
|
|
||||||
|
// Try ML-based prediction, clamped between physics bounds
|
||||||
|
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||||
|
final mlTimeout = _timeoutPredictionService?.predictTimeout(
|
||||||
|
contactKey: contactKey,
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
secondsSinceLastRx: secSinceRx,
|
||||||
|
);
|
||||||
|
if (mlTimeout != null) {
|
||||||
|
return mlTimeout.clamp(physicsMin, physicsMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
return physicsMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleContact(Uint8List frame, {bool isContact = true}) {
|
||||||
final contact = Contact.fromFrame(frame);
|
final contact = Contact.fromFrame(frame);
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
|
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
|
||||||
|
|
||||||
if (contact.type == advTypeRepeater) {
|
if (contact.type == advTypeRepeater) {
|
||||||
_contactUnreadCount.remove(contact.publicKeyHex);
|
_contactUnreadCount.remove(contact.publicKeyHex);
|
||||||
_unreadStore.saveContactUnreadCount(
|
_unreadStore.saveContactUnreadCount(
|
||||||
@@ -2694,11 +3064,23 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
tag: 'Connector',
|
tag: 'Connector',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
if ((_autoAddUsers && contact.type == advTypeChat) ||
|
||||||
|
(_autoAddRepeaters && contact.type == advTypeRepeater) ||
|
||||||
|
(_autoAddRoomServers && contact.type == advTypeRoom) ||
|
||||||
|
(_autoAddSensors && contact.type == advTypeSensor) ||
|
||||||
|
isContact) {
|
||||||
_contacts.add(contact);
|
_contacts.add(contact);
|
||||||
appLogger.info(
|
appLogger.info(
|
||||||
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
|
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
|
||||||
tag: 'Connector',
|
tag: 'Connector',
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
appLogger.info(
|
||||||
|
"Discovered contact ${contact.name} (type ${contact.typeLabel}) not added due to auto-add settings",
|
||||||
|
tag: 'Connector',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_knownContactKeys.add(contact.publicKeyHex);
|
_knownContactKeys.add(contact.publicKeyHex);
|
||||||
_loadMessagesForContact(contact.publicKeyHex);
|
_loadMessagesForContact(contact.publicKeyHex);
|
||||||
@@ -4261,6 +4643,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_batteryPollTimer?.cancel();
|
_batteryPollTimer?.cancel();
|
||||||
_receivedFramesController.close();
|
_receivedFramesController.close();
|
||||||
_usbManager.dispose();
|
_usbManager.dispose();
|
||||||
|
_tcpConnector.dispose();
|
||||||
|
|
||||||
// Flush pending unread writes before disposal
|
// Flush pending unread writes before disposal
|
||||||
_unreadStore.flush();
|
_unreadStore.flush();
|
||||||
@@ -4270,31 +4653,23 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
void _handleRxData(Uint8List frame) {
|
void _handleRxData(Uint8List frame) {
|
||||||
final packet = BufferReader(frame);
|
final packet = BufferReader(frame);
|
||||||
double snr = 0.0;
|
|
||||||
int routeType = 0;
|
|
||||||
int payloadType = 0;
|
|
||||||
Uint8List pathBytes = Uint8List(0);
|
|
||||||
Uint8List payload = Uint8List(0);
|
|
||||||
try {
|
try {
|
||||||
packet.skipBytes(1); // Skip frame type byte
|
packet.skipBytes(1); // Skip frame type byte
|
||||||
snr = packet.readInt8() / 4.0;
|
final snr = packet.readInt8() / 4.0;
|
||||||
packet.skipBytes(1); // Skip RSSI byte
|
packet.skipBytes(1); // Skip RSSI byte
|
||||||
//final rssi = packet.readByte();
|
//final rssi = packet.readByte();
|
||||||
final header = packet.readByte();
|
final header = packet.readByte();
|
||||||
routeType = header & 0x03;
|
final routeType = header & 0x03;
|
||||||
payloadType = (header >> 2) & 0x0F;
|
final payloadType = (header >> 2) & 0x0F;
|
||||||
if (routeType == _routeTransportFlood ||
|
if (routeType == _routeTransportFlood ||
|
||||||
routeType == _routeTransportDirect) {
|
routeType == _routeTransportDirect) {
|
||||||
packet.skipBytes(4); // Skip transport-specific bytes
|
packet.skipBytes(4); // Skip transport-specific bytes
|
||||||
}
|
}
|
||||||
//final payloadVer = (header >> 6) & 0x03;
|
//final payloadVer = (header >> 6) & 0x03;
|
||||||
final pathLen = packet.readByte();
|
final pathLen = packet.readByte();
|
||||||
pathBytes = packet.readBytes(pathLen);
|
final pathBytes = packet.readBytes(pathLen);
|
||||||
payload = packet.readBytes(packet.remaining);
|
final payload = packet.readBytes(packet.remaining);
|
||||||
} catch (e) {
|
|
||||||
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final rawPacket = frame.sublist(3);
|
final rawPacket = frame.sublist(3);
|
||||||
switch (payloadType) {
|
switch (payloadType) {
|
||||||
case payloadTypeADVERT:
|
case payloadTypeADVERT:
|
||||||
@@ -4308,6 +4683,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void importContact(Uint8List frame) {
|
void importContact(Uint8List frame) {
|
||||||
@@ -4332,8 +4711,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
double latitude = 0.0;
|
double? latitude;
|
||||||
double longitude = 0.0;
|
double? longitude;
|
||||||
String name = '';
|
String name = '';
|
||||||
Uint8List publicKey = Uint8List(0);
|
Uint8List publicKey = Uint8List(0);
|
||||||
int type = 0;
|
int type = 0;
|
||||||
@@ -4369,12 +4748,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
importDiscoveredContact(
|
importDiscoveredContact(
|
||||||
DiscoveryContact(
|
Contact(
|
||||||
rawPacket: frame,
|
rawPacket: frame,
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
pathLength: pathBytes.length,
|
pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
|
||||||
path: Uint8List.fromList(
|
path: Uint8List.fromList(
|
||||||
pathBytes.reversed.toList(),
|
pathBytes.reversed.toList(),
|
||||||
), // Store path in reverse for easier use in outgoing messages
|
), // Store path in reverse for easier use in outgoing messages
|
||||||
@@ -4393,8 +4772,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
double snr,
|
double snr,
|
||||||
) {
|
) {
|
||||||
final advert = BufferReader(payload);
|
final advert = BufferReader(payload);
|
||||||
double latitude = 0.0;
|
double? latitude;
|
||||||
double longitude = 0.0;
|
double? longitude;
|
||||||
String name = '';
|
String name = '';
|
||||||
String contactKeyHex = '';
|
String contactKeyHex = '';
|
||||||
Uint8List publicKey = Uint8List(0);
|
Uint8List publicKey = Uint8List(0);
|
||||||
@@ -4440,6 +4819,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
if (isNewContact) {
|
if (isNewContact) {
|
||||||
final newContact = Contact(
|
final newContact = Contact(
|
||||||
|
rawPacket: rawPacket,
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -4456,6 +4836,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
(_autoAddRoomServers && type == advTypeRoom) ||
|
(_autoAddRoomServers && type == advTypeRoom) ||
|
||||||
(_autoAddSensors && type == advTypeSensor)) {
|
(_autoAddSensors && type == advTypeSensor)) {
|
||||||
_handleContactAdvert(newContact);
|
_handleContactAdvert(newContact);
|
||||||
|
_handleDiscovery(
|
||||||
|
newContact,
|
||||||
|
rawPacket,
|
||||||
|
noNotify: true,
|
||||||
|
addActive: true,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_handleDiscovery(newContact, rawPacket);
|
_handleDiscovery(newContact, rawPacket);
|
||||||
}
|
}
|
||||||
@@ -4480,8 +4866,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
// CRITICAL: Preserve user's path override when contact is refreshed from device
|
// CRITICAL: Preserve user's path override when contact is refreshed from device
|
||||||
_contacts[existingIndex] = existing.copyWith(
|
_contacts[existingIndex] = existing.copyWith(
|
||||||
latitude: hasLocation ? latitude : existing.latitude,
|
latitude:
|
||||||
longitude: hasLocation ? longitude : existing.longitude,
|
hasLocation &&
|
||||||
|
latitude != null &&
|
||||||
|
latitude.abs() <= 90 &&
|
||||||
|
(latitude != 0 || longitude != 0)
|
||||||
|
? latitude
|
||||||
|
: existing.latitude,
|
||||||
|
longitude:
|
||||||
|
hasLocation &&
|
||||||
|
longitude != null &&
|
||||||
|
longitude.abs() <= 180 &&
|
||||||
|
(latitude != 0 || longitude != 0)
|
||||||
|
? longitude
|
||||||
|
: existing.longitude,
|
||||||
name: hasName ? name : existing.name,
|
name: hasName ? name : existing.name,
|
||||||
path: Uint8List.fromList(path.reversed.toList()),
|
path: Uint8List.fromList(path.reversed.toList()),
|
||||||
pathLength: path.length,
|
pathLength: path.length,
|
||||||
@@ -4552,11 +4950,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
reader.skipBytes(1); // Skip the response code byte
|
reader.skipBytes(1); // Skip the response code byte
|
||||||
final flags = reader.readByte();
|
final flags = reader.readByte();
|
||||||
_autoAddUsers = flags & autoAddChatFlag != 0;
|
_autoAddUsers = (flags & autoAddChatFlag) != 0;
|
||||||
_autoAddRepeaters = flags & autoAddRepeaterFlag != 0;
|
_autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0;
|
||||||
_autoAddRoomServers = flags & autoAddRoomServerFlag != 0;
|
_autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
|
||||||
_autoAddSensors = flags & autoAddSensorFlag != 0;
|
_autoAddSensors = (flags & autoAddSensorFlag) != 0;
|
||||||
_overwriteOldest = flags & autoAddOverwriteOldestFlag != 0;
|
_overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
|
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
|
||||||
}
|
}
|
||||||
@@ -4566,6 +4964,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
Contact contact,
|
Contact contact,
|
||||||
Uint8List rawPacket, {
|
Uint8List rawPacket, {
|
||||||
bool noNotify = false,
|
bool noNotify = false,
|
||||||
|
bool addActive = false,
|
||||||
}) {
|
}) {
|
||||||
appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
|
appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
|
||||||
|
|
||||||
@@ -4585,13 +4984,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
latitude: contact.latitude,
|
latitude: contact.latitude,
|
||||||
longitude: contact.longitude,
|
longitude: contact.longitude,
|
||||||
lastSeen: contact.lastSeen,
|
lastSeen: contact.lastSeen,
|
||||||
|
flags: 0,
|
||||||
|
isActive: addActive,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
unawaited(_persistDiscoveredContacts());
|
unawaited(_persistDiscoveredContacts());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final disContact = DiscoveryContact(
|
final disContact = Contact(
|
||||||
rawPacket: rawPacket,
|
rawPacket: rawPacket,
|
||||||
publicKey: contact.publicKey,
|
publicKey: contact.publicKey,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
@@ -4601,6 +5002,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
latitude: contact.latitude,
|
latitude: contact.latitude,
|
||||||
longitude: contact.longitude,
|
longitude: contact.longitude,
|
||||||
lastSeen: contact.lastSeen,
|
lastSeen: contact.lastSeen,
|
||||||
|
lastMessageAt: contact.lastMessageAt,
|
||||||
|
isActive: addActive,
|
||||||
|
flags: 0,
|
||||||
);
|
);
|
||||||
_discoveredContacts.add(disContact);
|
_discoveredContacts.add(disContact);
|
||||||
|
|
||||||
@@ -4624,6 +5028,25 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
unawaited(_persistDiscoveredContacts());
|
unawaited(_persistDiscoveredContacts());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clearMessagesForContact(Contact contact) {
|
||||||
|
final contactKeyHex = contact.publicKeyHex;
|
||||||
|
final messages = _conversations[contactKeyHex];
|
||||||
|
if (messages == null) return;
|
||||||
|
messages.clear();
|
||||||
|
unawaited(_messageStore.saveMessages(contactKeyHex, messages));
|
||||||
|
markContactRead(contactKeyHex);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearMessagesForChannel(int channelIndex) {
|
||||||
|
final messages = _channelMessages[channelIndex];
|
||||||
|
if (messages == null) return;
|
||||||
|
messages.clear();
|
||||||
|
unawaited(_channelMessageStore.saveChannelMessages(channelIndex, messages));
|
||||||
|
markChannelRead(channelIndex);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const int _phRouteMask = 0x03;
|
const int _phRouteMask = 0x03;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../services/app_debug_log_service.dart';
|
||||||
|
import '../services/tcp_transport_service.dart';
|
||||||
|
|
||||||
|
/// Manages TCP transport for MeshCore devices.
|
||||||
|
///
|
||||||
|
/// Owns the [TcpTransportService] and TCP-specific connection state.
|
||||||
|
/// The main [MeshCoreConnector] delegates all TCP operations here.
|
||||||
|
class MeshCoreTcpConnector {
|
||||||
|
final TcpTransportService _service = TcpTransportService();
|
||||||
|
AppDebugLogService? _debugLog;
|
||||||
|
StreamSubscription<Uint8List>? _frameSubscription;
|
||||||
|
|
||||||
|
// --- Getters ---
|
||||||
|
String? get activeEndpoint => _service.activeEndpoint;
|
||||||
|
bool get isConnected => _service.isConnected;
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLog = service;
|
||||||
|
_service.setDebugLogService(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Connection lifecycle ---
|
||||||
|
Future<void> connect({required String host, required int port}) async {
|
||||||
|
_debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP');
|
||||||
|
await _frameSubscription?.cancel();
|
||||||
|
_frameSubscription = null;
|
||||||
|
await _service.connect(host: host, port: port);
|
||||||
|
_debugLog?.info(
|
||||||
|
'TcpConnector.connect done, endpoint=${_service.activeEndpoint}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamSubscription<Uint8List> listenFrames({
|
||||||
|
required void Function(Uint8List) onFrame,
|
||||||
|
required void Function(Object, StackTrace?) onError,
|
||||||
|
required void Function() onDone,
|
||||||
|
}) {
|
||||||
|
_frameSubscription = _service.frameStream.listen(
|
||||||
|
onFrame,
|
||||||
|
onError: onError,
|
||||||
|
onDone: onDone,
|
||||||
|
);
|
||||||
|
return _frameSubscription!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelFrameSubscription() async {
|
||||||
|
await _frameSubscription?.cancel();
|
||||||
|
_frameSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
if (!_service.isConnected && _frameSubscription == null) return;
|
||||||
|
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
|
||||||
|
await _frameSubscription?.cancel();
|
||||||
|
_frameSubscription = null;
|
||||||
|
await _service.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) => _service.write(data);
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_frameSubscription?.cancel();
|
||||||
|
_service.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,7 @@ class MeshCoreUsbManager {
|
|||||||
// --- Configuration ---
|
// --- Configuration ---
|
||||||
Future<List<String>> listPorts() => _service.listPorts();
|
Future<List<String>> listPorts() => _service.listPorts();
|
||||||
|
|
||||||
void setRequestPortLabel(String label) =>
|
void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
|
||||||
_service.setRequestPortLabel(label);
|
|
||||||
|
|
||||||
void setFallbackDeviceName(String label) =>
|
void setFallbackDeviceName(String label) =>
|
||||||
_service.setFallbackDeviceName(label);
|
_service.setFallbackDeviceName(label);
|
||||||
@@ -36,7 +35,10 @@ class MeshCoreUsbManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Connection lifecycle ---
|
// --- Connection lifecycle ---
|
||||||
Future<void> connect({required String portName, int baudRate = 115200}) async {
|
Future<void> connect({
|
||||||
|
required String portName,
|
||||||
|
int baudRate = 115200,
|
||||||
|
}) async {
|
||||||
_debugLog?.info(
|
_debugLog?.info(
|
||||||
'UsbManager.connect: portName=$portName baud=$baudRate',
|
'UsbManager.connect: portName=$portName baud=$baudRate',
|
||||||
tag: 'USB',
|
tag: 'USB',
|
||||||
@@ -51,6 +53,9 @@ class MeshCoreUsbManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
|
if (!_service.isConnected && _activePortKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
|
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
|
||||||
await _service.disconnect();
|
await _service.disconnect();
|
||||||
_activePortKey = null;
|
_activePortKey = null;
|
||||||
@@ -59,6 +64,8 @@ class MeshCoreUsbManager {
|
|||||||
|
|
||||||
Future<void> write(Uint8List data) => _service.write(data);
|
Future<void> write(Uint8List data) => _service.write(data);
|
||||||
|
|
||||||
|
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
|
||||||
|
|
||||||
// --- Label management ---
|
// --- Label management ---
|
||||||
void updateConnectedLabel(String selfName) {
|
void updateConnectedLabel(String selfName) {
|
||||||
_service.updateConnectedLabel(selfName);
|
_service.updateConnectedLabel(selfName);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||||
class BufferReader {
|
class BufferReader {
|
||||||
int _pointer = 0;
|
int _pointer = 0;
|
||||||
|
int _lastPointer = 0;
|
||||||
final Uint8List _buffer;
|
final Uint8List _buffer;
|
||||||
|
|
||||||
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
|
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
|
||||||
@@ -13,6 +14,7 @@ class BufferReader {
|
|||||||
int readByte() => readBytes(1)[0];
|
int readByte() => readBytes(1)[0];
|
||||||
|
|
||||||
Uint8List readBytes(int count) {
|
Uint8List readBytes(int count) {
|
||||||
|
_lastPointer = _pointer;
|
||||||
if (_pointer + count > _buffer.length) {
|
if (_pointer + count > _buffer.length) {
|
||||||
throw RangeError(
|
throw RangeError(
|
||||||
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||||
@@ -24,6 +26,7 @@ class BufferReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void skipBytes(int count) {
|
void skipBytes(int count) {
|
||||||
|
_lastPointer = _pointer;
|
||||||
if (_pointer + count > _buffer.length) {
|
if (_pointer + count > _buffer.length) {
|
||||||
throw RangeError(
|
throw RangeError(
|
||||||
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||||
@@ -35,6 +38,7 @@ class BufferReader {
|
|||||||
Uint8List readRemainingBytes() => readBytes(remaining);
|
Uint8List readRemainingBytes() => readBytes(remaining);
|
||||||
|
|
||||||
String readString() {
|
String readString() {
|
||||||
|
_lastPointer = _pointer;
|
||||||
final value = readRemainingBytes();
|
final value = readRemainingBytes();
|
||||||
try {
|
try {
|
||||||
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||||
@@ -43,7 +47,8 @@ class BufferReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String readCString(int maxLength) {
|
String readCStringGreedy(int maxLength) {
|
||||||
|
_lastPointer = _pointer;
|
||||||
final value = <int>[];
|
final value = <int>[];
|
||||||
final bytes = readBytes(maxLength);
|
final bytes = readBytes(maxLength);
|
||||||
for (final byte in bytes) {
|
for (final byte in bytes) {
|
||||||
@@ -57,6 +62,24 @@ class BufferReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String readCString(int maxLength) {
|
||||||
|
final backupPointer = _pointer;
|
||||||
|
final value = <int>[];
|
||||||
|
int counter = 0;
|
||||||
|
while (counter < maxLength) {
|
||||||
|
final byte = readByte();
|
||||||
|
if (byte == 0) break;
|
||||||
|
value.add(byte);
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
_lastPointer = backupPointer;
|
||||||
|
try {
|
||||||
|
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||||
|
} catch (e) {
|
||||||
|
return String.fromCharCodes(value); // Latin-1 fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
|
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
|
||||||
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
|
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
|
||||||
int readUInt16LE() =>
|
int readUInt16LE() =>
|
||||||
@@ -78,6 +101,9 @@ class BufferReader {
|
|||||||
if ((value & 0x800000) != 0) value -= 0x1000000;
|
if ((value & 0x800000) != 0) value -= 0x1000000;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void resetPointer() => _pointer = 0;
|
||||||
|
void rewind() => _pointer = _lastPointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buffer Writer - accumulating binary data builder
|
// Buffer Writer - accumulating binary data builder
|
||||||
@@ -122,6 +148,19 @@ class BufferWriter {
|
|||||||
void writeHex(String hex) {
|
void writeHex(String hex) {
|
||||||
writeBytes(hex2Uint8List(hex));
|
writeBytes(hex2Uint8List(hex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void writeBytesPadded(Uint8List bytes, int totalLength) {
|
||||||
|
// Path data (64 bytes, zero-padded)
|
||||||
|
final bytesPadded = Uint8List(totalLength);
|
||||||
|
final len = bytes.length < totalLength ? bytes.length : totalLength;
|
||||||
|
if (bytes.isNotEmpty && len > 0) {
|
||||||
|
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
|
||||||
|
for (int i = 0; i < copyLen; i++) {
|
||||||
|
bytesPadded[i] = bytes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeBytes(bytesPadded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List hex2Uint8List(String hex) {
|
Uint8List hex2Uint8List(String hex) {
|
||||||
@@ -171,7 +210,7 @@ const int cmdSetChannel = 32;
|
|||||||
const int cmdSendTracePath = 36;
|
const int cmdSendTracePath = 36;
|
||||||
const int cmdSetOtherParams = 38;
|
const int cmdSetOtherParams = 38;
|
||||||
const int cmdSendAnonReq = 57;
|
const int cmdSendAnonReq = 57;
|
||||||
const int cmdGetTelemetryReq = 39;
|
const int cmdSendTelemetryReq = 39;
|
||||||
const int cmdGetCustomVar = 40;
|
const int cmdGetCustomVar = 40;
|
||||||
const int cmdSetCustomVar = 41;
|
const int cmdSetCustomVar = 41;
|
||||||
const int cmdSendBinaryReq = 50;
|
const int cmdSendBinaryReq = 50;
|
||||||
@@ -233,6 +272,10 @@ const int advTypeRepeater = 2;
|
|||||||
const int advTypeRoom = 3;
|
const int advTypeRoom = 3;
|
||||||
const int advTypeSensor = 4;
|
const int advTypeSensor = 4;
|
||||||
|
|
||||||
|
const int teleModeDeny = 0;
|
||||||
|
const int teleModeAllowFlags = 1; // use contact.flags
|
||||||
|
const int teleModeAllowAll = 2;
|
||||||
|
|
||||||
// Payload Types
|
// Payload Types
|
||||||
const int payloadTypeREQ =
|
const int payloadTypeREQ =
|
||||||
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||||||
@@ -313,6 +356,9 @@ const int contactPubKeyOffset = 1;
|
|||||||
const int contactTypeOffset = 33;
|
const int contactTypeOffset = 33;
|
||||||
const int contactFlagsOffset = 34;
|
const int contactFlagsOffset = 34;
|
||||||
const int contactFlagFavorite = 0x01;
|
const int contactFlagFavorite = 0x01;
|
||||||
|
const int contactFlagTeleBase = 0x02; // 'base' permission includes battery
|
||||||
|
const int contactFlagTeleLoc = 0x04;
|
||||||
|
const int contactFlagTeleEnv = 0x08; //access environment sensors
|
||||||
const int contactPathLenOffset = 35;
|
const int contactPathLenOffset = 35;
|
||||||
const int contactPathOffset = 36;
|
const int contactPathOffset = 36;
|
||||||
const int contactNameOffset = 100;
|
const int contactNameOffset = 100;
|
||||||
@@ -650,14 +696,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
||||||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
|
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
|
||||||
Uint8List buildUpdateContactPathFrame(
|
Uint8List buildUpdateContactPathFrame(
|
||||||
Uint8List pubKey,
|
Uint8List pubKey,
|
||||||
Uint8List customPath,
|
Uint8List path,
|
||||||
int pathLen, {
|
int pathLen, {
|
||||||
int type = 1, // ADV_TYPE_CHAT
|
int type = 1, // ADV_TYPE_CHAT
|
||||||
int flags = 0,
|
int flags = 0,
|
||||||
String name = '',
|
String name = '',
|
||||||
|
double? lat,
|
||||||
|
double? lon,
|
||||||
|
DateTime? lastModified,
|
||||||
}) {
|
}) {
|
||||||
final writer = BufferWriter();
|
final writer = BufferWriter();
|
||||||
writer.writeByte(cmdAddUpdateContact);
|
writer.writeByte(cmdAddUpdateContact);
|
||||||
@@ -666,17 +715,7 @@ Uint8List buildUpdateContactPathFrame(
|
|||||||
writer.writeByte(flags);
|
writer.writeByte(flags);
|
||||||
writer.writeByte(pathLen);
|
writer.writeByte(pathLen);
|
||||||
|
|
||||||
// Path data (64 bytes, zero-padded)
|
writer.writeBytesPadded(path, maxPathSize);
|
||||||
final pathPadded = Uint8List(maxPathSize);
|
|
||||||
if (customPath.isNotEmpty && pathLen > 0) {
|
|
||||||
final copyLen = customPath.length < maxPathSize
|
|
||||||
? customPath.length
|
|
||||||
: maxPathSize;
|
|
||||||
for (int i = 0; i < copyLen; i++) {
|
|
||||||
pathPadded[i] = customPath[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writer.writeBytes(pathPadded);
|
|
||||||
|
|
||||||
// Name (32 bytes, null-padded)
|
// Name (32 bytes, null-padded)
|
||||||
writer.writeCString(name, maxNameSize);
|
writer.writeCString(name, maxNameSize);
|
||||||
@@ -685,6 +724,27 @@ Uint8List buildUpdateContactPathFrame(
|
|||||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
writer.writeUInt32LE(timestamp);
|
writer.writeUInt32LE(timestamp);
|
||||||
|
|
||||||
|
if ((lat == null || lon == null) && lastModified != null) {
|
||||||
|
// If lat/lon not provided, write zeros
|
||||||
|
writer.writeInt32LE(0);
|
||||||
|
writer.writeInt32LE(0);
|
||||||
|
} else {
|
||||||
|
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
|
||||||
|
// Latitude
|
||||||
|
final latitude = lat ?? 0.0;
|
||||||
|
writer.writeInt32LE((latitude * 1e6).round());
|
||||||
|
|
||||||
|
// Longitude
|
||||||
|
final longitude = lon ?? 0.0;
|
||||||
|
writer.writeInt32LE((longitude * 1e6).round());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastModified != null) {
|
||||||
|
// Last modified
|
||||||
|
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
writer.writeUInt32LE(lastModifiedTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
return writer.toBytes();
|
return writer.toBytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,3 +944,18 @@ Uint8List buildSetAutoAddConfigFrame({
|
|||||||
writer.writeByte(flags);
|
writer.writeByte(flags);
|
||||||
return writer.toBytes();
|
return writer.toBytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Build CMD_SEND_TELEMETRY_REQ
|
||||||
|
// Format: [cmd][reserved x3][pub_key? x32]
|
||||||
|
Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
|
||||||
|
final writer = BufferWriter();
|
||||||
|
writer.writeByte(cmdSendTelemetryReq);
|
||||||
|
|
||||||
|
if (pubKey != null && pubKey.length == pubKeySize) {
|
||||||
|
writer.writeBytes(Uint8List(3)); // reserved bytes
|
||||||
|
writer.writeBytes(pubKey);
|
||||||
|
} else {
|
||||||
|
writer.writeBytes(Uint8List(4)); // reserved bytes
|
||||||
|
}
|
||||||
|
return writer.toBytes();
|
||||||
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Нова група",
|
"contacts_newGroup": "Нова група",
|
||||||
"contacts_groupName": "Група",
|
"contacts_groupName": "Група",
|
||||||
"contacts_groupNameRequired": "Името на групата е задължително.",
|
"contacts_groupNameRequired": "Името на групата е задължително.",
|
||||||
|
"contacts_groupNameReserved": "Това име на група е запазено",
|
||||||
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
|
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbConnectionFailed": "Неуспешно свързване през USB: {error}",
|
"usbConnectionFailed": "Неуспешно свързване през USB: {error}",
|
||||||
"usbStatus_notConnected": "Изберете USB устройство",
|
"usbStatus_notConnected": "Изберете USB устройство",
|
||||||
"usbStatus_searching": "Търсене на USB устройства...",
|
"usbStatus_searching": "Търсене на USB устройства...",
|
||||||
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка."
|
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "Свържете се чрез TCP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostLabel": "IP адрес",
|
||||||
|
"tcpPortLabel": "Пристанище",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Въведете крайната точка и свържете се.",
|
||||||
|
"tcpStatus_connectingTo": "Свързване към {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Необходим е IP адрес.",
|
||||||
|
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
|
||||||
|
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
||||||
|
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||||
|
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||||
|
"map_setAsMyLocation": "Задайте като моя местоположение",
|
||||||
|
"settings_denyAll": "Откажи всичко",
|
||||||
|
"settings_allowAll": "Позволи всичко",
|
||||||
|
"settings_allowByContact": "Позволи по флагове за контакт",
|
||||||
|
"settings_privacy": "Настройки на поверителността",
|
||||||
|
"settings_privacySettingsDescription": "Изберете каква информация устройството ви споделя с другите.",
|
||||||
|
"settings_privacySubtitle": "Контролирайте каква информация се споделя.",
|
||||||
|
"settings_telemetryBaseMode": "Базов режим на телеметрия",
|
||||||
|
"settings_telemetryLocationMode": "Режим на местоположение на телеметрията",
|
||||||
|
"settings_advertLocation": "Място на обявата",
|
||||||
|
"settings_advertLocationSubtitle": "Включи местоположение в обявата",
|
||||||
|
"contact_info": "Контактна информация",
|
||||||
|
"settings_telemetryEnvironmentMode": "Режим на средата на телеметрията",
|
||||||
|
"contact_telemetry": "Телеметрия",
|
||||||
|
"contact_lastSeen": "Последно видян",
|
||||||
|
"contact_clearChat": "Изчисти чата",
|
||||||
|
"contact_teleBase": "Базата данни за телеметрия",
|
||||||
|
"contact_settings": "Настройки за контакти",
|
||||||
|
"contact_teleBaseSubtitle": "Позволи споделяне на ниво на батерията и основна телеметрия",
|
||||||
|
"contact_teleEnv": "Среда на телеметрия",
|
||||||
|
"contact_teleLocSubtitle": "Позволи споделяне на данни за местоположение",
|
||||||
|
"contact_teleLoc": "Местоположение на телеметрията",
|
||||||
|
"contact_teleEnvSubtitle": "Позволи споделяне на данни от средносферните датчици",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_multiAck": "Мулти-потвърди: {value}",
|
||||||
|
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Neue Gruppe",
|
"contacts_newGroup": "Neue Gruppe",
|
||||||
"contacts_groupName": "Gruppenname",
|
"contacts_groupName": "Gruppenname",
|
||||||
"contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.",
|
"contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.",
|
||||||
|
"contacts_groupNameReserved": "Dieser Gruppenname ist reserviert",
|
||||||
"contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.",
|
"contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1887,5 +1888,65 @@
|
|||||||
"usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
|
"usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
|
||||||
"usbStatus_connecting": "Verbindung zum USB-Gerät...",
|
"usbStatus_connecting": "Verbindung zum USB-Gerät...",
|
||||||
"usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}",
|
"usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}",
|
||||||
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält."
|
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "IP-Adresse",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "Verbinden über TCP",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.",
|
||||||
|
"tcpStatus_connectingTo": "Verbindung zu {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.",
|
||||||
|
"tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.",
|
||||||
|
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
|
||||||
|
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
||||||
|
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
|
||||||
|
"map_setAsMyLocation": "Als meine aktuelle Position festlegen",
|
||||||
|
"settings_allowByContact": "Zulassen durch Kontaktflaggen",
|
||||||
|
"settings_privacy": "Datenschutzeinstellungen",
|
||||||
|
"settings_allowAll": "Alles zulassen",
|
||||||
|
"settings_privacySettingsDescription": "Wählen Sie die Informationen, die Ihr Gerät mit anderen teilt.",
|
||||||
|
"settings_denyAll": "Alle ablehnen",
|
||||||
|
"settings_privacySubtitle": "Steuern Sie die Informationen, die freigegeben werden.",
|
||||||
|
"settings_telemetryLocationMode": "Telemetrie-Ortsmodus",
|
||||||
|
"settings_telemetryEnvironmentMode": "Telemetrie-Umgebungsmodus",
|
||||||
|
"settings_advertLocation": "Anzeigenort",
|
||||||
|
"settings_advertLocationSubtitle": "Ort in der Anzeige einbeziehen",
|
||||||
|
"settings_telemetryBaseMode": "Telemetrie-Basismodus",
|
||||||
|
"contact_teleBase": "Telemetriebasis",
|
||||||
|
"contact_teleBaseSubtitle": "Erlauben des Freigebens des Batteriestands und der grundlegenden Telemetrie",
|
||||||
|
"contact_teleLoc": "Telemetrieort",
|
||||||
|
"contact_teleLocSubtitle": "Teilen von Standortdaten zulassen",
|
||||||
|
"contact_info": "Kontaktinformationen",
|
||||||
|
"contact_settings": "Kontakteinstellungen",
|
||||||
|
"contact_telemetry": "Telemetrie",
|
||||||
|
"contact_teleEnv": "Telemetrieumgebung",
|
||||||
|
"contact_lastSeen": "Zuletzt gesehen",
|
||||||
|
"contact_clearChat": "Chat löschen",
|
||||||
|
"contact_teleEnvSubtitle": "Teilen von Umgebungsensordaten zulassen",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Telemetriemodus aktualisiert",
|
||||||
|
"settings_multiAck": "Mehrfach-Bestätigungen: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,33 @@
|
|||||||
"scanner_title": "MeshCore Open",
|
"scanner_title": "MeshCore Open",
|
||||||
"connectionChoiceUsbLabel": "USB",
|
"connectionChoiceUsbLabel": "USB",
|
||||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpScreenTitle": "Connect over TCP",
|
||||||
|
"tcpHostLabel": "IP Address",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Enter endpoint and connect",
|
||||||
|
"tcpStatus_connectingTo": "Connecting to {endpoint}...",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpErrorHostRequired": "IP address is required.",
|
||||||
|
"tcpErrorPortInvalid": "Port must be between 1 and 65535.",
|
||||||
|
"tcpErrorUnsupported": "TCP transport is not supported on this platform.",
|
||||||
|
"tcpErrorTimedOut": "TCP connection timed out.",
|
||||||
|
"tcpConnectionFailed": "TCP connection failed: {error}",
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"usbScreenTitle": "Connect over USB",
|
"usbScreenTitle": "Connect over USB",
|
||||||
"usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
|
"usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
|
||||||
"usbScreenStatus": "Select a USB device",
|
"usbScreenStatus": "Select a USB device",
|
||||||
@@ -139,6 +166,26 @@
|
|||||||
"settings_privacyModeToggle": "Toggle privacy mode to hide your name and location in advertisements.",
|
"settings_privacyModeToggle": "Toggle privacy mode to hide your name and location in advertisements.",
|
||||||
"settings_privacyModeEnabled": "Privacy mode enabled",
|
"settings_privacyModeEnabled": "Privacy mode enabled",
|
||||||
"settings_privacyModeDisabled": "Privacy mode disabled",
|
"settings_privacyModeDisabled": "Privacy mode disabled",
|
||||||
|
"settings_privacy": "Privacy Settings",
|
||||||
|
"settings_privacySubtitle": "Control what information is shared.",
|
||||||
|
"settings_privacySettingsDescription": "Choose what information your device shares with others.",
|
||||||
|
"settings_denyAll": "Deny all",
|
||||||
|
"settings_allowByContact": "Allow by contact flags",
|
||||||
|
"settings_allowAll": "Allow all",
|
||||||
|
"settings_telemetryBaseMode": "Telemetry Base Mode",
|
||||||
|
"settings_telemetryLocationMode": "Telemetry Location Mode",
|
||||||
|
"settings_telemetryEnvironmentMode": "Telemetry Environment Mode",
|
||||||
|
"settings_advertLocation": "Advert Location",
|
||||||
|
"settings_advertLocationSubtitle": "Include location in advert.",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Telemetry mode updated",
|
||||||
"settings_actions": "Actions",
|
"settings_actions": "Actions",
|
||||||
"settings_sendAdvertisement": "Send Advertisement",
|
"settings_sendAdvertisement": "Send Advertisement",
|
||||||
"settings_sendAdvertisementSubtitle": "Broadcast presence now",
|
"settings_sendAdvertisementSubtitle": "Broadcast presence now",
|
||||||
@@ -389,6 +436,7 @@
|
|||||||
"contacts_newGroup": "New Group",
|
"contacts_newGroup": "New Group",
|
||||||
"contacts_groupName": "Group name",
|
"contacts_groupName": "Group name",
|
||||||
"contacts_groupNameRequired": "Group name is required",
|
"contacts_groupNameRequired": "Group name is required",
|
||||||
|
"contacts_groupNameReserved": "This group name is reserved",
|
||||||
"contacts_groupAlreadyExists": "Group \"{name}\" already exists",
|
"contacts_groupAlreadyExists": "Group \"{name}\" already exists",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -427,6 +475,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"contact_info": "Contact Info",
|
||||||
|
"contact_settings": "Contact Settings",
|
||||||
|
"contact_telemetry": "Telemetry",
|
||||||
|
"contact_lastSeen": "Last seen",
|
||||||
|
"contact_clearChat": "Clear Chat",
|
||||||
|
"contact_teleBase": "Telemetry Base",
|
||||||
|
"contact_teleBaseSubtitle": "Allow sharing battery level and basic telemetry",
|
||||||
|
"contact_teleLoc": "Telemetry Location",
|
||||||
|
"contact_teleLocSubtitle": "Allow sharing location data",
|
||||||
|
"contact_teleEnv": "Telemetry Environment",
|
||||||
|
"contact_teleEnvSubtitle": "Allow sharing environment sensor data",
|
||||||
"channels_title": "Channels",
|
"channels_title": "Channels",
|
||||||
"channels_noChannelsConfigured": "No channels configured",
|
"channels_noChannelsConfigured": "No channels configured",
|
||||||
"channels_addPublicChannel": "Add Public Channel",
|
"channels_addPublicChannel": "Add Public Channel",
|
||||||
@@ -780,6 +839,7 @@
|
|||||||
"map_source": "Source",
|
"map_source": "Source",
|
||||||
"map_flags": "Flags",
|
"map_flags": "Flags",
|
||||||
"map_shareMarkerHere": "Share marker here",
|
"map_shareMarkerHere": "Share marker here",
|
||||||
|
"map_setAsMyLocation": "Set as my location",
|
||||||
"map_pinLabel": "Pin label",
|
"map_pinLabel": "Pin label",
|
||||||
"map_label": "Label",
|
"map_label": "Label",
|
||||||
"map_pointOfInterest": "Point of interest",
|
"map_pointOfInterest": "Point of interest",
|
||||||
@@ -807,6 +867,7 @@
|
|||||||
"map_markers": "Markers",
|
"map_markers": "Markers",
|
||||||
"map_showSharedMarkers": "Show shared markers",
|
"map_showSharedMarkers": "Show shared markers",
|
||||||
"map_showGuessedLocations": "Show guessed node locations",
|
"map_showGuessedLocations": "Show guessed node locations",
|
||||||
|
"map_showDiscoveryContacts": "Show Discovery Contacts",
|
||||||
"map_guessedLocation": "Guessed location",
|
"map_guessedLocation": "Guessed location",
|
||||||
"map_lastSeenTime": "Last Seen Time",
|
"map_lastSeenTime": "Last Seen Time",
|
||||||
"map_sharedPin": "Shared pin",
|
"map_sharedPin": "Shared pin",
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nuevo Grupo",
|
"contacts_newGroup": "Nuevo Grupo",
|
||||||
"contacts_groupName": "Nombre del grupo",
|
"contacts_groupName": "Nombre del grupo",
|
||||||
"contacts_groupNameRequired": "El nombre del grupo es obligatorio",
|
"contacts_groupNameRequired": "El nombre del grupo es obligatorio",
|
||||||
|
"contacts_groupNameReserved": "Este nombre de grupo está reservado",
|
||||||
"contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe",
|
"contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1887,5 +1888,65 @@
|
|||||||
"usbStatus_searching": "Buscando dispositivos USB...",
|
"usbStatus_searching": "Buscando dispositivos USB...",
|
||||||
"usbStatus_notConnected": "Seleccione un dispositivo USB",
|
"usbStatus_notConnected": "Seleccione un dispositivo USB",
|
||||||
"usbConnectionFailed": "Error al conectar mediante USB: {error}",
|
"usbConnectionFailed": "Error al conectar mediante USB: {error}",
|
||||||
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion."
|
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpScreenTitle": "Establecer conexión a través de TCP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpHostLabel": "Dirección IP",
|
||||||
|
"tcpPortLabel": "Puerto",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Ingrese la dirección final y conecte.",
|
||||||
|
"tcpStatus_connectingTo": "Conectándose a {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Se requiere la dirección IP.",
|
||||||
|
"tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.",
|
||||||
|
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
|
||||||
|
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
||||||
|
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
|
||||||
|
"map_setAsMyLocation": "Establecer mi ubicación",
|
||||||
|
"settings_privacySubtitle": "Controlar qué información se comparte.",
|
||||||
|
"settings_allowByContact": "Permitir por banderas de contacto",
|
||||||
|
"settings_denyAll": "Denegar todo",
|
||||||
|
"settings_telemetryBaseMode": "Modo base de telemetría",
|
||||||
|
"settings_telemetryEnvironmentMode": "Modo de entorno de telemetría",
|
||||||
|
"settings_advertLocationSubtitle": "Incluir ubicación en anuncio",
|
||||||
|
"contact_info": "Información de contacto",
|
||||||
|
"settings_privacySettingsDescription": "Elige qué información comparte tu dispositivo con otros.",
|
||||||
|
"settings_allowAll": "Permitir todo",
|
||||||
|
"settings_privacy": "Configuración de privacidad",
|
||||||
|
"contact_settings": "Configuración de contacto",
|
||||||
|
"settings_telemetryLocationMode": "Modo de ubicación de telemetría",
|
||||||
|
"contact_teleBase": "Base de Telemetría",
|
||||||
|
"contact_teleLoc": "Ubicación de telemetría",
|
||||||
|
"settings_advertLocation": "Ubicación de anuncio",
|
||||||
|
"contact_teleLocSubtitle": "Permitir el intercambio de datos de ubicación",
|
||||||
|
"contact_clearChat": "Borrar chat",
|
||||||
|
"contact_telemetry": "Telemetría",
|
||||||
|
"contact_lastSeen": "Visto por última vez",
|
||||||
|
"contact_teleBaseSubtitle": "Permitir el intercambio de nivel de batería y telemetría básica",
|
||||||
|
"contact_teleEnv": "Entorno de Telemetría",
|
||||||
|
"contact_teleEnvSubtitle": "Permitir el intercambio de datos de sensores de entorno",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Modo de telemetría actualizado",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nouveau Groupe",
|
"contacts_newGroup": "Nouveau Groupe",
|
||||||
"contacts_groupName": "Nom du groupe",
|
"contacts_groupName": "Nom du groupe",
|
||||||
"contacts_groupNameRequired": "Le nom du groupe est obligatoire.",
|
"contacts_groupNameRequired": "Le nom du groupe est obligatoire.",
|
||||||
|
"contacts_groupNameReserved": "Ce nom de groupe est réservé",
|
||||||
"contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.",
|
"contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbConnectionFailed": "Échec de la connexion USB : {error}",
|
"usbConnectionFailed": "Échec de la connexion USB : {error}",
|
||||||
"usbStatus_connecting": "Connexion au périphérique USB...",
|
"usbStatus_connecting": "Connexion au périphérique USB...",
|
||||||
"usbStatus_searching": "Recherche de périphériques USB...",
|
"usbStatus_searching": "Recherche de périphériques USB...",
|
||||||
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion."
|
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "Adresse IP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpScreenTitle": "Établir une connexion via TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.",
|
||||||
|
"tcpStatus_connectingTo": "Connexion à {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Une adresse IP est obligatoire.",
|
||||||
|
"tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.",
|
||||||
|
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
|
||||||
|
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
||||||
|
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
||||||
|
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
|
||||||
|
"map_setAsMyLocation": "Définir comme ma localisation",
|
||||||
|
"settings_privacy": "Paramètres de confidentialité",
|
||||||
|
"settings_privacySubtitle": "Contrôlez les informations partagées",
|
||||||
|
"settings_telemetryLocationMode": "Mode d'emplacement de télémétrie",
|
||||||
|
"settings_telemetryEnvironmentMode": "Mode d'environnement de télémétrie",
|
||||||
|
"settings_advertLocation": "Emplacement de l'annonce",
|
||||||
|
"settings_advertLocationSubtitle": "Inclure l'emplacement dans l'annonce",
|
||||||
|
"settings_denyAll": "Refuser tout",
|
||||||
|
"settings_allowByContact": "Autoriser par drapeaux de contact",
|
||||||
|
"settings_privacySettingsDescription": "Choisissez les informations que votre appareil partage avec les autres.",
|
||||||
|
"settings_allowAll": "Autoriser tout",
|
||||||
|
"contact_info": "Informations de contact",
|
||||||
|
"settings_telemetryBaseMode": "Mode de base Télémétrie",
|
||||||
|
"contact_teleBase": "Base de télémétrie",
|
||||||
|
"contact_teleLoc": "Emplacement de télémétrie",
|
||||||
|
"contact_teleLocSubtitle": "Autoriser le partage des données de localisation",
|
||||||
|
"contact_teleEnv": "Environnement Télémétrie",
|
||||||
|
"contact_teleEnvSubtitle": "Autoriser le partage des données des capteurs d'environnement",
|
||||||
|
"contact_telemetry": "Télémétrie",
|
||||||
|
"contact_settings": "Paramètres de contact",
|
||||||
|
"contact_lastSeen": "Dernière fois vu",
|
||||||
|
"contact_clearChat": "Effacer la conversation",
|
||||||
|
"contact_teleBaseSubtitle": "Autoriser le partage du niveau de batterie et de la télémétrie de base",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_multiAck": "Multi-ACKs : {value}",
|
||||||
|
"settings_telemetryModeUpdated": "Le mode télémétrie a été mis à jour"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nuovo Gruppo",
|
"contacts_newGroup": "Nuovo Gruppo",
|
||||||
"contacts_groupName": "Nome gruppo",
|
"contacts_groupName": "Nome gruppo",
|
||||||
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
||||||
|
"contacts_groupNameReserved": "Questo nome del gruppo è riservato",
|
||||||
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
|
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbConnectionFailed": "Errore nella connessione USB: {error}",
|
"usbConnectionFailed": "Errore nella connessione USB: {error}",
|
||||||
"usbStatus_notConnected": "Seleziona un dispositivo USB",
|
"usbStatus_notConnected": "Seleziona un dispositivo USB",
|
||||||
"usbStatus_connecting": "Connessione al dispositivo USB...",
|
"usbStatus_connecting": "Connessione al dispositivo USB...",
|
||||||
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion."
|
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "Indirizzo IP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpScreenTitle": "Stabilire una connessione tramite TCP",
|
||||||
|
"tcpPortLabel": "Porta",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.",
|
||||||
|
"tcpStatus_connectingTo": "Connessione a {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "È necessario fornire un indirizzo IP.",
|
||||||
|
"tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.",
|
||||||
|
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
|
||||||
|
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
||||||
|
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
|
||||||
|
"map_setAsMyLocation": "Imposta come la mia posizione",
|
||||||
|
"settings_privacySettingsDescription": "Scegli le informazioni che il tuo dispositivo condivide con gli altri.",
|
||||||
|
"settings_allowByContact": "Consenti in base ai flag di contatto",
|
||||||
|
"settings_telemetryLocationMode": "Modalità di posizionamento telemetrico",
|
||||||
|
"settings_telemetryEnvironmentMode": "Modalità di ambiente di telemetria",
|
||||||
|
"settings_advertLocation": "Posizione dell'annuncio",
|
||||||
|
"settings_advertLocationSubtitle": "Includi la posizione nell'annuncio",
|
||||||
|
"settings_privacy": "Impostazioni sulla privacy",
|
||||||
|
"settings_denyAll": "Negare tutto",
|
||||||
|
"settings_privacySubtitle": "Controlla le informazioni che vengono condivise.",
|
||||||
|
"settings_allowAll": "Consenti tutto",
|
||||||
|
"contact_info": "Informazioni di Contatto",
|
||||||
|
"settings_telemetryBaseMode": "Modalità di base di telemetria",
|
||||||
|
"contact_teleBase": "Base di telemetria",
|
||||||
|
"contact_teleLoc": "Posizione telemetria",
|
||||||
|
"contact_teleLocSubtitle": "Consenti la condivisione dei dati di posizione",
|
||||||
|
"contact_clearChat": "Cancella chat",
|
||||||
|
"contact_telemetry": "Telemetria",
|
||||||
|
"contact_settings": "Impostazioni di contatto",
|
||||||
|
"contact_lastSeen": "Ultimo accesso",
|
||||||
|
"contact_teleBaseSubtitle": "Consenti la condivisione del livello della batteria e della telemetria di base",
|
||||||
|
"contact_teleEnvSubtitle": "Consenti la condivisione dei dati del sensore ambientale",
|
||||||
|
"contact_teleEnv": "Ambiente di telemetria",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Modalità telemetria aggiornata",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,6 +334,84 @@ abstract class AppLocalizations {
|
|||||||
/// **'Bluetooth'**
|
/// **'Bluetooth'**
|
||||||
String get connectionChoiceBluetoothLabel;
|
String get connectionChoiceBluetoothLabel;
|
||||||
|
|
||||||
|
/// No description provided for @connectionChoiceTcpLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TCP'**
|
||||||
|
String get connectionChoiceTcpLabel;
|
||||||
|
|
||||||
|
/// No description provided for @tcpScreenTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connect over TCP'**
|
||||||
|
String get tcpScreenTitle;
|
||||||
|
|
||||||
|
/// No description provided for @tcpHostLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'IP Address'**
|
||||||
|
String get tcpHostLabel;
|
||||||
|
|
||||||
|
/// No description provided for @tcpHostHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'192.168.40.10'**
|
||||||
|
String get tcpHostHint;
|
||||||
|
|
||||||
|
/// No description provided for @tcpPortLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Port'**
|
||||||
|
String get tcpPortLabel;
|
||||||
|
|
||||||
|
/// No description provided for @tcpPortHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'5000'**
|
||||||
|
String get tcpPortHint;
|
||||||
|
|
||||||
|
/// No description provided for @tcpStatus_notConnected.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enter endpoint and connect'**
|
||||||
|
String get tcpStatus_notConnected;
|
||||||
|
|
||||||
|
/// No description provided for @tcpStatus_connectingTo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connecting to {endpoint}...'**
|
||||||
|
String tcpStatus_connectingTo(String endpoint);
|
||||||
|
|
||||||
|
/// No description provided for @tcpErrorHostRequired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'IP address is required.'**
|
||||||
|
String get tcpErrorHostRequired;
|
||||||
|
|
||||||
|
/// No description provided for @tcpErrorPortInvalid.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Port must be between 1 and 65535.'**
|
||||||
|
String get tcpErrorPortInvalid;
|
||||||
|
|
||||||
|
/// No description provided for @tcpErrorUnsupported.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TCP transport is not supported on this platform.'**
|
||||||
|
String get tcpErrorUnsupported;
|
||||||
|
|
||||||
|
/// No description provided for @tcpErrorTimedOut.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TCP connection timed out.'**
|
||||||
|
String get tcpErrorTimedOut;
|
||||||
|
|
||||||
|
/// No description provided for @tcpConnectionFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TCP connection failed: {error}'**
|
||||||
|
String tcpConnectionFailed(String error);
|
||||||
|
|
||||||
/// No description provided for @usbScreenTitle.
|
/// No description provided for @usbScreenTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -748,6 +826,84 @@ abstract class AppLocalizations {
|
|||||||
/// **'Privacy mode disabled'**
|
/// **'Privacy mode disabled'**
|
||||||
String get settings_privacyModeDisabled;
|
String get settings_privacyModeDisabled;
|
||||||
|
|
||||||
|
/// No description provided for @settings_privacy.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Privacy Settings'**
|
||||||
|
String get settings_privacy;
|
||||||
|
|
||||||
|
/// No description provided for @settings_privacySubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Control what information is shared.'**
|
||||||
|
String get settings_privacySubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @settings_privacySettingsDescription.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose what information your device shares with others.'**
|
||||||
|
String get settings_privacySettingsDescription;
|
||||||
|
|
||||||
|
/// No description provided for @settings_denyAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Deny all'**
|
||||||
|
String get settings_denyAll;
|
||||||
|
|
||||||
|
/// No description provided for @settings_allowByContact.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow by contact flags'**
|
||||||
|
String get settings_allowByContact;
|
||||||
|
|
||||||
|
/// No description provided for @settings_allowAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow all'**
|
||||||
|
String get settings_allowAll;
|
||||||
|
|
||||||
|
/// No description provided for @settings_telemetryBaseMode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Base Mode'**
|
||||||
|
String get settings_telemetryBaseMode;
|
||||||
|
|
||||||
|
/// No description provided for @settings_telemetryLocationMode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Location Mode'**
|
||||||
|
String get settings_telemetryLocationMode;
|
||||||
|
|
||||||
|
/// No description provided for @settings_telemetryEnvironmentMode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Environment Mode'**
|
||||||
|
String get settings_telemetryEnvironmentMode;
|
||||||
|
|
||||||
|
/// No description provided for @settings_advertLocation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Advert Location'**
|
||||||
|
String get settings_advertLocation;
|
||||||
|
|
||||||
|
/// No description provided for @settings_advertLocationSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Include location in advert.'**
|
||||||
|
String get settings_advertLocationSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @settings_multiAck.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Multi-ACKs: {value}'**
|
||||||
|
String settings_multiAck(String value);
|
||||||
|
|
||||||
|
/// No description provided for @settings_telemetryModeUpdated.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry mode updated'**
|
||||||
|
String get settings_telemetryModeUpdated;
|
||||||
|
|
||||||
/// No description provided for @settings_actions.
|
/// No description provided for @settings_actions.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1636,6 +1792,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Group name is required'**
|
/// **'Group name is required'**
|
||||||
String get contacts_groupNameRequired;
|
String get contacts_groupNameRequired;
|
||||||
|
|
||||||
|
/// No description provided for @contacts_groupNameReserved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This group name is reserved'**
|
||||||
|
String get contacts_groupNameReserved;
|
||||||
|
|
||||||
/// No description provided for @contacts_groupAlreadyExists.
|
/// No description provided for @contacts_groupAlreadyExists.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1696,6 +1858,72 @@ abstract class AppLocalizations {
|
|||||||
/// **'~ {days} days'**
|
/// **'~ {days} days'**
|
||||||
String contacts_lastSeenDaysAgo(int days);
|
String contacts_lastSeenDaysAgo(int days);
|
||||||
|
|
||||||
|
/// No description provided for @contact_info.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Contact Info'**
|
||||||
|
String get contact_info;
|
||||||
|
|
||||||
|
/// No description provided for @contact_settings.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Contact Settings'**
|
||||||
|
String get contact_settings;
|
||||||
|
|
||||||
|
/// No description provided for @contact_telemetry.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry'**
|
||||||
|
String get contact_telemetry;
|
||||||
|
|
||||||
|
/// No description provided for @contact_lastSeen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Last seen'**
|
||||||
|
String get contact_lastSeen;
|
||||||
|
|
||||||
|
/// No description provided for @contact_clearChat.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear Chat'**
|
||||||
|
String get contact_clearChat;
|
||||||
|
|
||||||
|
/// No description provided for @contact_teleBase.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Base'**
|
||||||
|
String get contact_teleBase;
|
||||||
|
|
||||||
|
/// No description provided for @contact_teleBaseSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow sharing battery level and basic telemetry'**
|
||||||
|
String get contact_teleBaseSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @contact_teleLoc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Location'**
|
||||||
|
String get contact_teleLoc;
|
||||||
|
|
||||||
|
/// No description provided for @contact_teleLocSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow sharing location data'**
|
||||||
|
String get contact_teleLocSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @contact_teleEnv.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Environment'**
|
||||||
|
String get contact_teleEnv;
|
||||||
|
|
||||||
|
/// No description provided for @contact_teleEnvSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow sharing environment sensor data'**
|
||||||
|
String get contact_teleEnvSubtitle;
|
||||||
|
|
||||||
/// No description provided for @channels_title.
|
/// No description provided for @channels_title.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2668,6 +2896,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Share marker here'**
|
/// **'Share marker here'**
|
||||||
String get map_shareMarkerHere;
|
String get map_shareMarkerHere;
|
||||||
|
|
||||||
|
/// No description provided for @map_setAsMyLocation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Set as my location'**
|
||||||
|
String get map_setAsMyLocation;
|
||||||
|
|
||||||
/// No description provided for @map_pinLabel.
|
/// No description provided for @map_pinLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2788,6 +3022,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Show guessed node locations'**
|
/// **'Show guessed node locations'**
|
||||||
String get map_showGuessedLocations;
|
String get map_showGuessedLocations;
|
||||||
|
|
||||||
|
/// No description provided for @map_showDiscoveryContacts.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show Discovery Contacts'**
|
||||||
|
String get map_showDiscoveryContacts;
|
||||||
|
|
||||||
/// No description provided for @map_guessedLocation.
|
/// No description provided for @map_guessedLocation.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -117,6 +117,50 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Свържете се чрез TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP адрес';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Пристанище';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Свързване към $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Необходим е IP адрес.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Портът трябва да бъде между 1 и 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Транспортът чрез TCP не се поддържа на тази платформа.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Връзката TCP изтекла.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Неуспешно е установено TCP връзката: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Свържете се чрез USB';
|
String get usbScreenTitle => 'Свържете се чрез USB';
|
||||||
|
|
||||||
@@ -354,6 +398,52 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
String get settings_privacyModeDisabled =>
|
String get settings_privacyModeDisabled =>
|
||||||
'Режим на поверителност е деактивиран';
|
'Режим на поверителност е деактивиран';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Настройки на поверителността';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Контролирайте каква информация се споделя.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Изберете каква информация устройството ви споделя с другите.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Откажи всичко';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Позволи по флагове за контакт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Позволи всичко';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Базов режим на телеметрия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Режим на местоположение на телеметрията';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Режим на средата на телеметрията';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Място на обявата';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Включи местоположение в обявата';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Мулти-потвърди: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Режим на телеметрията е обновен';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Действия';
|
String get settings_actions => 'Действия';
|
||||||
|
|
||||||
@@ -858,6 +948,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Името на групата е задължително.';
|
String get contacts_groupNameRequired => 'Името на групата е задължително.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Това име на група е запазено';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Групата \"$name\" вече съществува.';
|
return 'Групата \"$name\" вече съществува.';
|
||||||
@@ -897,6 +990,42 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
return 'Последно видян $days дни преди.';
|
return 'Последно видян $days дни преди.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Контактна информация';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Настройки за контакти';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Телеметрия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Последно видян';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Изчисти чата';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Базата данни за телеметрия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Позволи споделяне на ниво на батерията и основна телеметрия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Местоположение на телеметрията';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle =>
|
||||||
|
'Позволи споделяне на данни за местоположение';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Среда на телеметрия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Позволи споделяне на данни от средносферните датчици';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Канали';
|
String get channels_title => 'Канали';
|
||||||
|
|
||||||
@@ -1467,6 +1596,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Споделете маркер тук';
|
String get map_shareMarkerHere => 'Споделете маркер тук';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Задайте като моя местоположение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Етикетиране на пин';
|
String get map_pinLabel => 'Етикетиране на пин';
|
||||||
|
|
||||||
@@ -1531,6 +1663,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Покажете местоположенията на предположените възли.';
|
'Покажете местоположенията на предположените възли.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Предполагано местоположение';
|
String get map_guessedLocation => 'Предполагано местоположение';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,52 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Verbinden über TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-Adresse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected =>
|
||||||
|
'Geben Sie den Endpunkt ein und verbinden Sie sich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Verbindung zu $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Eine IP-Adresse ist erforderlich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'Die Portnummer muss zwischen 1 und 65535 liegen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Die TCP-Verbindung ist abgelaufen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Fehler beim TCP-Verbindungsaufbau: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Verbinden über USB';
|
String get usbScreenTitle => 'Verbinden über USB';
|
||||||
|
|
||||||
@@ -352,6 +398,50 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Datenschutzmodus deaktiviert';
|
String get settings_privacyModeDisabled => 'Datenschutzmodus deaktiviert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Datenschutzeinstellungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Steuern Sie die Informationen, die freigegeben werden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Wählen Sie die Informationen, die Ihr Gerät mit anderen teilt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Alle ablehnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Zulassen durch Kontaktflaggen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Alles zulassen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Telemetrie-Basismodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Telemetrie-Ortsmodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Telemetrie-Umgebungsmodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Anzeigenort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Ort in der Anzeige einbeziehen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Mehrfach-Bestätigungen: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Telemetriemodus aktualisiert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Aktionen';
|
String get settings_actions => 'Aktionen';
|
||||||
|
|
||||||
@@ -856,6 +946,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
|
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Die Gruppe \"$name\" existiert bereits.';
|
return 'Die Gruppe \"$name\" existiert bereits.';
|
||||||
@@ -895,6 +988,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return '~ $days Tage';
|
return '~ $days Tage';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Kontaktinformationen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Kontakteinstellungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Zuletzt gesehen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Chat löschen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Telemetriebasis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Erlauben des Freigebens des Batteriestands und der grundlegenden Telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Telemetrieort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle => 'Teilen von Standortdaten zulassen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Telemetrieumgebung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Teilen von Umgebungsensordaten zulassen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Kanäle';
|
String get channels_title => 'Kanäle';
|
||||||
|
|
||||||
@@ -1467,6 +1595,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
|
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Pin Name';
|
String get map_pinLabel => 'Pin Name';
|
||||||
|
|
||||||
@@ -1531,6 +1662,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Zeige die vermuteten Knotenpositionen';
|
'Zeige die vermuteten Knotenpositionen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Geschätzter Ort';
|
String get map_guessedLocation => 'Geschätzter Ort';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,50 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Connect over TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP Address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Enter endpoint and connect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Connecting to $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'IP address is required.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Port must be between 1 and 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'TCP transport is not supported on this platform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'TCP connection timed out.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'TCP connection failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Connect over USB';
|
String get usbScreenTitle => 'Connect over USB';
|
||||||
|
|
||||||
@@ -348,6 +392,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Privacy mode disabled';
|
String get settings_privacyModeDisabled => 'Privacy mode disabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Privacy Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => 'Control what information is shared.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Choose what information your device shares with others.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Deny all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Allow by contact flags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Allow all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Telemetry Base Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Telemetry Location Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Telemetry Environment Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Advert Location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Include location in advert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Telemetry mode updated';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Actions';
|
String get settings_actions => 'Actions';
|
||||||
|
|
||||||
@@ -845,6 +931,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Group name is required';
|
String get contacts_groupNameRequired => 'Group name is required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'This group name is reserved';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Group \"$name\" already exists';
|
return 'Group \"$name\" already exists';
|
||||||
@@ -883,6 +972,40 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '~ $days days';
|
return '~ $days days';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Contact Info';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Contact Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Last seen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Clear Chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Telemetry Base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Allow sharing battery level and basic telemetry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Telemetry Location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle => 'Allow sharing location data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Telemetry Environment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle => 'Allow sharing environment sensor data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Channels';
|
String get channels_title => 'Channels';
|
||||||
|
|
||||||
@@ -1443,6 +1566,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Share marker here';
|
String get map_shareMarkerHere => 'Share marker here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Set as my location';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Pin label';
|
String get map_pinLabel => 'Pin label';
|
||||||
|
|
||||||
@@ -1506,6 +1632,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_showGuessedLocations => 'Show guessed node locations';
|
String get map_showGuessedLocations => 'Show guessed node locations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Guessed location';
|
String get map_guessedLocation => 'Guessed location';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,50 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Establecer conexión a través de TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Dirección IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Puerto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Conectándose a $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Se requiere la dirección IP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'El puerto debe estar entre 1 y 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'El protocolo de transporte TCP no está soportado en esta plataforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'La conexión TCP ha caducado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Error en la conexión TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Conecte mediante USB';
|
String get usbScreenTitle => 'Conecte mediante USB';
|
||||||
|
|
||||||
@@ -352,6 +396,51 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Modo de privacidad desactivado';
|
String get settings_privacyModeDisabled => 'Modo de privacidad desactivado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Configuración de privacidad';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Controlar qué información se comparte.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Elige qué información comparte tu dispositivo con otros.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Denegar todo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Permitir por banderas de contacto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Permitir todo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Modo base de telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Modo de ubicación de telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Modo de entorno de telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Ubicación de anuncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Incluir ubicación en anuncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Modo de telemetría actualizado';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Acciones';
|
String get settings_actions => 'Acciones';
|
||||||
|
|
||||||
@@ -857,6 +946,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
|
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved =>
|
||||||
|
'Este nombre de grupo está reservado';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'El grupo \"$name\" ya existe';
|
return 'El grupo \"$name\" ya existe';
|
||||||
@@ -896,6 +989,42 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return '~ $days días';
|
return '~ $days días';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Información de contacto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Configuración de contacto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Visto por última vez';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Borrar chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Base de Telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Permitir el intercambio de nivel de batería y telemetría básica';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Ubicación de telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle =>
|
||||||
|
'Permitir el intercambio de datos de ubicación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Entorno de Telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Permitir el intercambio de datos de sensores de entorno';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Canales';
|
String get channels_title => 'Canales';
|
||||||
|
|
||||||
@@ -1465,6 +1594,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Compartir marcador aquí';
|
String get map_shareMarkerHere => 'Compartir marcador aquí';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Establecer mi ubicación';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Etiqueta de marcador';
|
String get map_pinLabel => 'Etiqueta de marcador';
|
||||||
|
|
||||||
@@ -1529,6 +1661,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Mostrar las ubicaciones estimadas de los nodos.';
|
'Mostrar las ubicaciones estimadas de los nodos.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Ubicación estimada';
|
String get map_guessedLocation => 'Ubicación estimada';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,52 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Établir une connexion via TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Adresse IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected =>
|
||||||
|
'Entrez l\'adresse de destination et connectez-vous.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Connexion à $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Une adresse IP est obligatoire.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'La taille du port doit être comprise entre 1 et 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Le protocole TCP n\'est pas pris en charge sur cette plateforme.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'La connexion TCP a expiré.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Échec de la connexion TCP : $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Connectez via USB';
|
String get usbScreenTitle => 'Connectez via USB';
|
||||||
|
|
||||||
@@ -354,6 +400,52 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get settings_privacyModeDisabled =>
|
String get settings_privacyModeDisabled =>
|
||||||
'Mode de confidentialité désactivé';
|
'Mode de confidentialité désactivé';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Paramètres de confidentialité';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => 'Contrôlez les informations partagées';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Choisissez les informations que votre appareil partage avec les autres.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Refuser tout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Autoriser par drapeaux de contact';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Autoriser tout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Mode de base Télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Mode d\'emplacement de télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Mode d\'environnement de télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Emplacement de l\'annonce';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Inclure l\'emplacement dans l\'annonce';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs : $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated =>
|
||||||
|
'Le mode télémétrie a été mis à jour';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Actions';
|
String get settings_actions => 'Actions';
|
||||||
|
|
||||||
@@ -859,6 +951,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
|
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Ce nom de groupe est réservé';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Le groupe \"$name\" existe déjà.';
|
return 'Le groupe \"$name\" existe déjà.';
|
||||||
@@ -898,6 +993,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return '~ $days jours';
|
return '~ $days jours';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Informations de contact';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Paramètres de contact';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Dernière fois vu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Effacer la conversation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Base de télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Autoriser le partage du niveau de batterie et de la télémétrie de base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Emplacement de télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle =>
|
||||||
|
'Autoriser le partage des données de localisation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Environnement Télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Autoriser le partage des données des capteurs d\'environnement';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Canaux';
|
String get channels_title => 'Canaux';
|
||||||
|
|
||||||
@@ -1472,6 +1603,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Partager le marqueur ici';
|
String get map_shareMarkerHere => 'Partager le marqueur ici';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Définir comme ma localisation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Étiquete de repin';
|
String get map_pinLabel => 'Étiquete de repin';
|
||||||
|
|
||||||
@@ -1536,6 +1670,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Afficher les emplacements des nœuds estimés';
|
'Afficher les emplacements des nœuds estimés';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Lieu deviné';
|
String get map_guessedLocation => 'Lieu deviné';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,51 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Stabilire una connessione tramite TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Indirizzo IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Porta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Connessione a $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'È necessario fornire un indirizzo IP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'La dimensione della porta deve essere compresa tra 1 e 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Il protocollo TCP non è supportato su questa piattaforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'La connessione TCP è scaduta.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Impossibile stabilire la connessione TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Connessione tramite USB';
|
String get usbScreenTitle => 'Connessione tramite USB';
|
||||||
|
|
||||||
@@ -353,6 +398,52 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Modalità privacy disabilitata';
|
String get settings_privacyModeDisabled => 'Modalità privacy disabilitata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Impostazioni sulla privacy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Controlla le informazioni che vengono condivise.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Scegli le informazioni che il tuo dispositivo condivide con gli altri.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Negare tutto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Consenti in base ai flag di contatto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Consenti tutto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Modalità di base di telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Modalità di posizionamento telemetrico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Modalità di ambiente di telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Posizione dell\'annuncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Includi la posizione nell\'annuncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Modalità telemetria aggiornata';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Azioni';
|
String get settings_actions => 'Azioni';
|
||||||
|
|
||||||
@@ -856,6 +947,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
|
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Il gruppo \"$name\" esiste già.';
|
return 'Il gruppo \"$name\" esiste già.';
|
||||||
@@ -895,6 +989,42 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return 'Ultimo visto $days giorni fa';
|
return 'Ultimo visto $days giorni fa';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Informazioni di Contatto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Impostazioni di contatto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Ultimo accesso';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Cancella chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Base di telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Consenti la condivisione del livello della batteria e della telemetria di base';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Posizione telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle =>
|
||||||
|
'Consenti la condivisione dei dati di posizione';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Ambiente di telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Consenti la condivisione dei dati del sensore ambientale';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Canali';
|
String get channels_title => 'Canali';
|
||||||
|
|
||||||
@@ -1465,6 +1595,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Condividi marcatore qui';
|
String get map_shareMarkerHere => 'Condividi marcatore qui';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Imposta come la mia posizione';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Etichetta PIN';
|
String get map_pinLabel => 'Etichetta PIN';
|
||||||
|
|
||||||
@@ -1528,6 +1661,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
|
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Località indovinata';
|
String get map_guessedLocation => 'Località indovinata';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,51 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Verbind via TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-adres';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Poort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Verbinding maken met $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Een IP-adres is vereist.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'De poortwaarde moet tussen 1 en 65535 liggen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'TCP-transport wordt niet ondersteund op deze platform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'De TCP-verbinding is verlopen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Verbinding met TCP mislukt: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Verbind via USB';
|
String get usbScreenTitle => 'Verbind via USB';
|
||||||
|
|
||||||
@@ -350,6 +395,50 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Privacy modus is uitgeschakeld';
|
String get settings_privacyModeDisabled => 'Privacy modus is uitgeschakeld';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Privacyinstellingen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Beheer welke informatie wordt gedeeld';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Kies welke informatie uw apparaat deelt met anderen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Weiger alles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Toestaan op basis van contactvlaggen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Alles toestaan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Telemetrie-basismodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Telemetrie-locatiemodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Telemetrie-omgevingsmodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Advertentielocatie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Locatie opnemen in advertentie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Telemetrie-modus bijgewerkt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Acties';
|
String get settings_actions => 'Acties';
|
||||||
|
|
||||||
@@ -850,6 +939,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
|
String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'De groep \"$name\" bestaat al.';
|
return 'De groep \"$name\" bestaat al.';
|
||||||
@@ -889,6 +981,40 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return 'Laast gezien $days dagen geleden';
|
return 'Laast gezien $days dagen geleden';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Contactinformatie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Contactinstellingen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Laatst gezien';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Chat leegmaken';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Telemetrie_basis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Sta delen van batterij niveau en basis telemetrie toe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Telemetrielocatie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle => 'Locatiegegevens delen toestaan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Telemetrieomgeving';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle => 'Delen van omgevingsensordata toestaan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Kanaal';
|
String get channels_title => 'Kanaal';
|
||||||
|
|
||||||
@@ -1457,6 +1583,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Deel marker hier';
|
String get map_shareMarkerHere => 'Deel marker hier';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Label vastzetten';
|
String get map_pinLabel => 'Label vastzetten';
|
||||||
|
|
||||||
@@ -1521,6 +1650,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Toon de voorspelde locaties van de knopen';
|
'Toon de voorspelde locaties van de knopen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Geroerde locatie';
|
String get map_guessedLocation => 'Geroerde locatie';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,52 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Połącz się za pomocą protokołu TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Adres IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Połączenie z $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Wymagana jest adresa IP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'Numer portu musi mieścić się w zakresie od 1 do 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut =>
|
||||||
|
'Połączenie TCP zakończyło się bez powodzenia.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Błąd połączenia TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Połącz przez USB';
|
String get usbScreenTitle => 'Połącz przez USB';
|
||||||
|
|
||||||
@@ -355,6 +401,52 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Tryb prywatności wyłączony';
|
String get settings_privacyModeDisabled => 'Tryb prywatności wyłączony';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Ustawienia prywatności';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Kontroluj jakie informacje są udostępniane.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Wybierz jakie informacje urządzenie udostępni innym.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Odmów wszystkim';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Zezwalaj według flag kontaktowych';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Zezwalaj na wszystko';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Tryb podstawowy telemetrii';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Tryb położenia telemetrycznego';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Tryb środowiska telemetrycznego';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Lokalizacja reklamowa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Uwzględnij lokalizację w ogłoszeniu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Wiele potwierdzeń: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated =>
|
||||||
|
'Tryb telemetryczny zaktualizowany';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Działania';
|
String get settings_actions => 'Działania';
|
||||||
|
|
||||||
@@ -858,6 +950,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana';
|
String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Ta nazwa grupy jest zastrzeżona';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Grupa \"$name\" już istnieje';
|
return 'Grupa \"$name\" już istnieje';
|
||||||
@@ -897,6 +992,42 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
return 'Ostatnie połączenie $days dni temu';
|
return 'Ostatnie połączenie $days dni temu';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Informacje kontaktowe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Ustawienia kontaktowe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetryka';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Ostatnio widziany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Wyczyść czat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Baza telemetryczna';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Pozwól na udostępnianie poziomu naładowania baterii i podstawowych danych telemetrycznych';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Lokalizacja telemetryczna';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle =>
|
||||||
|
'Zezwalaj na udostępnianie danych lokalizacji';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Środowisko telemetryczne';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Zezwalaj na udostępnianie danych czujników środowiskowych';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Kanały';
|
String get channels_title => 'Kanały';
|
||||||
|
|
||||||
@@ -1466,6 +1597,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
|
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Oznacz etykietę';
|
String get map_pinLabel => 'Oznacz etykietę';
|
||||||
|
|
||||||
@@ -1530,6 +1664,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Wyświetl lokalizacje zgadanych węzłów';
|
'Wyświetl lokalizacje zgadanych węzłów';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Wydana lokalizacja';
|
String get map_guessedLocation => 'Wydana lokalizacja';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,51 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Estabelecer conexão via TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Endereço IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Porta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Conectando a $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'É necessário fornecer um endereço IP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'O valor do porto deve estar entre 1 e 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'O protocolo TCP não é suportado nesta plataforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'A conexão TCP expirou.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Falha na conexão TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Conecte via USB';
|
String get usbScreenTitle => 'Conecte via USB';
|
||||||
|
|
||||||
@@ -353,6 +398,51 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Modo de privacidade desativado';
|
String get settings_privacyModeDisabled => 'Modo de privacidade desativado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Configurações de Privacidade';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => 'Controle o que é compartilhado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Escolha quais informações o seu dispositivo compartilha com os outros.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Negar todos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Permitir por bandeiras de contato';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Permitir todos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Modo Base de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Modo de Localização de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Modo de Ambiente de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Localização do Anúncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Incluir localização no anúncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Modo de telemetria atualizado';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Ações';
|
String get settings_actions => 'Ações';
|
||||||
|
|
||||||
@@ -858,6 +948,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
|
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Este nome de grupo está reservado';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'O grupo \"$name\" já existe';
|
return 'O grupo \"$name\" já existe';
|
||||||
@@ -897,6 +990,42 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Última vez visto $days dias atrás';
|
return 'Última vez visto $days dias atrás';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Informações de Contato';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Configurações de Contato';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Visto pela última vez';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Limpar Chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Base de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Permitir compartilhamento do nível da bateria e telemetria básica';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Localização de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle =>
|
||||||
|
'Permitir compartilhamento de dados de localização';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Ambiente de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Permitir compartilhamento de dados do sensor de ambiente';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Canais';
|
String get channels_title => 'Canais';
|
||||||
|
|
||||||
@@ -1466,6 +1595,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
|
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Defina minha localização';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Rótulo de marcador';
|
String get map_pinLabel => 'Rótulo de marcador';
|
||||||
|
|
||||||
@@ -1530,6 +1662,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Mostrar as localizações dos nós estimados';
|
'Mostrar as localizações dos nós estimados';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Localização estimada';
|
String get map_guessedLocation => 'Localização estimada';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,51 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Установить соединение по протоколу TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-адрес';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Порт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Введите адрес и подключитесь.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Подключение к $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Необходимо указать IP-адрес.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'Порт должен находиться в диапазоне от 1 до 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Протокол TCP не поддерживается на этой платформе.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Соединение TCP не удалось установить.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Не удалось установить соединение TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Подключение через USB';
|
String get usbScreenTitle => 'Подключение через USB';
|
||||||
|
|
||||||
@@ -353,6 +398,51 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get settings_privacyModeDisabled =>
|
String get settings_privacyModeDisabled =>
|
||||||
'Режим конфиденциальности выключен';
|
'Режим конфиденциальности выключен';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Настройки конфиденциальности';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Контролируйте, какую информацию делиться.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Выберите, какую информацию ваше устройство будет делиться с другими.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Отклонить все';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Разрешить по флагам контактов';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Разрешить все';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Базовый режим телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Режим местоположения телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Режим среды телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Местоположение рекламы';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Включить местоположение в объявление';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Мульти-ACK: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Режим телеметрии обновлен';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Действия';
|
String get settings_actions => 'Действия';
|
||||||
|
|
||||||
@@ -857,6 +947,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Имя группы обязательно';
|
String get contacts_groupNameRequired => 'Имя группы обязательно';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Это имя группы зарезервировано';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Группа \"$name\" уже существует';
|
return 'Группа \"$name\" уже существует';
|
||||||
@@ -896,6 +989,42 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Видели $days дн. назад';
|
return 'Видели $days дн. назад';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Контактная информация';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Настройки контактов';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Телеметрия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Последний раз видели';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Очистить чат';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'База телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Разрешить обмен уровнем заряда батареи и базовой телеметрией';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Местоположение телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle =>
|
||||||
|
'Разрешить обмен данными о местоположении';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Среда телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Разрешить обмен данными датчиков окружающей среды';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Каналы';
|
String get channels_title => 'Каналы';
|
||||||
|
|
||||||
@@ -1468,6 +1597,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Поделиться меткой здесь';
|
String get map_shareMarkerHere => 'Поделиться меткой здесь';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Установить мое местоположение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Метка';
|
String get map_pinLabel => 'Метка';
|
||||||
|
|
||||||
@@ -1532,6 +1664,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Отобразить предполагаемые места расположения узлов';
|
'Отобразить предполагаемые места расположения узлов';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Угаданное место';
|
String get map_guessedLocation => 'Угаданное место';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,50 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Spojte sa pomocou protokolu TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP adresa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Pripojenie k $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Je potrebné zadať IP adresu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Číslo portu musí byť medzi 1 a 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Pripojenie TCP vypršalo.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Neúspešné vytvorenie TCP spojenia: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Pripojte cez USB';
|
String get usbScreenTitle => 'Pripojte cez USB';
|
||||||
|
|
||||||
@@ -351,6 +395,49 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Ochranný režim je vypnutý';
|
String get settings_privacyModeDisabled => 'Ochranný režim je vypnutý';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Nastavenia súkromia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => 'Ovládni, aké informácie sa zdieľajú.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Vyberte, ktoré informácie váš zariadenie zdieľa s ostatnými.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Zamietnuť všetko';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Povoliť podľa kontaktových vlajok';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Povoliť všetko';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Základný režim telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Režim umiestnenia telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Režim prostredia telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Umiestnenie inzerátu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Zahrnúť polohu do inzerátu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Viaceré ACK: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated =>
|
||||||
|
'Režim telemetrie bol aktualizovaný';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Možné akcie';
|
String get settings_actions => 'Možné akcie';
|
||||||
|
|
||||||
@@ -850,6 +937,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Skupina musí mať názov.';
|
String get contacts_groupNameRequired => 'Skupina musí mať názov.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Skupina \"$name\" už existuje';
|
return 'Skupina \"$name\" už existuje';
|
||||||
@@ -891,6 +981,41 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
return 'Posledné zobrazenie $days dní dozadu';
|
return 'Posledné zobrazenie $days dní dozadu';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Kontaktné informácie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Nastavenia kontaktov';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Naposledy videný';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Vymazať chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Báza telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Povoliť zdieľanie úrovne batérie a základnej telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Lokácia telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle => 'Povoliť zdieľanie údajov o lokalite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Prostredie telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Povoliť zdieľanie údajov senzorov prostredia';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Kanály';
|
String get channels_title => 'Kanály';
|
||||||
|
|
||||||
@@ -1460,6 +1585,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Zdieľte značku tu';
|
String get map_shareMarkerHere => 'Zdieľte značku tu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Označka upozornenia';
|
String get map_pinLabel => 'Označka upozornenia';
|
||||||
|
|
||||||
@@ -1524,6 +1652,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Zobraziť umiestnenia odhadnutých uzlov';
|
'Zobraziť umiestnenia odhadnutých uzlov';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Odhadnutá lokalita';
|
String get map_guessedLocation => 'Odhadnutá lokalita';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,50 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Komunicirajte preko protokola TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP naslov';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Vrata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Povezava z $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Potrebna je IP-naslov.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Port mora biti med 1 in 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Transport preko protokola TCP ni podprt na tej platformi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Povezava TCP je presegla časovno obdobje.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Napaka pri povezavi TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Povežite preko USB';
|
String get usbScreenTitle => 'Povežite preko USB';
|
||||||
|
|
||||||
@@ -349,6 +393,50 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Privatni način je onemogočen.';
|
String get settings_privacyModeDisabled => 'Privatni način je onemogočen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Nastavitve zasebnosti';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Kontrolirajte, katere informacije so deljene.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Izberite, katere informacije vaš naprava deli z drugimi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Zavrniti vse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Dovoli po kontaktnih zastavah';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Dovoli vse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Osnovni način telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Način delovanja telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Način delovanja okolja telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Lokacija oglasa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Vključi lokacijo v oglas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Večkratni potrditvi: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Način telemetrije posodobljen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Akcije';
|
String get settings_actions => 'Akcije';
|
||||||
|
|
||||||
@@ -848,6 +936,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
|
String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'To ime skupine je rezervirano';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Skupina \"$name\" že obstaja';
|
return 'Skupina \"$name\" že obstaja';
|
||||||
@@ -887,6 +978,41 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
return 'Zadnjič viden pred $days dnem';
|
return 'Zadnjič viden pred $days dnem';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Kontaktni podatki';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Nastavitve stika';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetrija';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Zadnjič videno';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Počisti klepet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Baza telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Dovoli deljenje stanja baterije in osnovne telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Lokacija telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle => 'Dovoli deljenje podatkov o lokaciji';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Okolje telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Dovoli deljenje podatkov okoljskih senzorjev';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Kanali';
|
String get channels_title => 'Kanali';
|
||||||
|
|
||||||
@@ -1454,6 +1580,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Delite točke tukaj.';
|
String get map_shareMarkerHere => 'Delite točke tukaj.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Oznaka za pritrditev';
|
String get map_pinLabel => 'Oznaka za pritrditev';
|
||||||
|
|
||||||
@@ -1517,6 +1646,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
|
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Predpostavljena lokacija';
|
String get map_guessedLocation => 'Predpostavljena lokacija';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,50 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Anslut via TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-adress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Ange slutpunkt och anslut';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Anslutning till $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'IP-adress krävs.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Porten måste vara mellan 1 och 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'TCP-transport fungerar inte på denna plattform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'TCP-anslutningen har tidsut gått.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Fel vid TCP-anslutning: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Anslut via USB';
|
String get usbScreenTitle => 'Anslut via USB';
|
||||||
|
|
||||||
@@ -348,6 +392,49 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Privatläge är avstängt';
|
String get settings_privacyModeDisabled => 'Privatläge är avstängt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Inställningar för sekretess';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Kontrollera vilken information som delas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Välj vilken information din enhet delar med andra.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Neka alla';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Tillåt via kontaktflaggor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Tillåt alla';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Telemetribasläge';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Telemetritillstånd för plats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Telemetri miljöläge';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Annonsplacering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Inkludera plats i annonsen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Telemetri-läge uppdaterat';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Åtgärder';
|
String get settings_actions => 'Åtgärder';
|
||||||
|
|
||||||
@@ -844,6 +931,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
|
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Gruppen \"$name\" finns redan.';
|
return 'Gruppen \"$name\" finns redan.';
|
||||||
@@ -883,6 +973,40 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
return 'Senast synlig $days dagar sedan';
|
return 'Senast synlig $days dagar sedan';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Kontaktinformation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Kontaktinställningar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Telemetri';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Senast sedd';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Rensa Chatt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Telemetribas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Tillåt delning av batterinivå och grundläggande telemetri';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Telemetridata plats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle => 'Tillåt delning av platsdata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Telemetri Miljö';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle => 'Tillåt delning av miljösensordata';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Kanaler';
|
String get channels_title => 'Kanaler';
|
||||||
|
|
||||||
@@ -1450,6 +1574,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Dela markeringen här';
|
String get map_shareMarkerHere => 'Dela markeringen här';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Ange som min plats';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Fästetikett';
|
String get map_pinLabel => 'Fästetikett';
|
||||||
|
|
||||||
@@ -1514,6 +1641,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Visa upp de antagna nodernas placeringar';
|
'Visa upp de antagna nodernas placeringar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Gissad plats';
|
String get map_guessedLocation => 'Gissad plats';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,51 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'З\'єднатися через протокол TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-адреса';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Порт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Підключення до $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Необхідно вказати IP-адресу.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Порт повинен бути в межах від 1 до 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Транспорт TCP не підтримується на цій платформі.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut =>
|
||||||
|
'З\'єднання TCP завершилося через закінчення часу очікування.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Не вдалося встановити з\'єднання TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => 'Підключити через USB';
|
String get usbScreenTitle => 'Підключити через USB';
|
||||||
|
|
||||||
@@ -350,6 +395,50 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Режим приватності вимкнено';
|
String get settings_privacyModeDisabled => 'Режим приватності вимкнено';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Налаштування приватності';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Керуйте інформацією, яку буде спільно використовуватися';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Виберіть, яку інформацію ваш пристрій буде передавати іншим.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Відхилити все';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Дозволити за контактними прапорцями';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Дозволити все';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Режим базової телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Режим місця телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Режим середовища телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Розміщення реклами';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Включити місце розташування в оголошення';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Багатократне підтвердження: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Режим телеметрії оновлено';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Дії';
|
String get settings_actions => 'Дії';
|
||||||
|
|
||||||
@@ -853,6 +942,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
|
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => 'Ця назва групи зарезервована';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return 'Група «$name» вже існує.';
|
return 'Група «$name» вже існує.';
|
||||||
@@ -892,6 +984,42 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
return 'В мережі $days дн. тому';
|
return 'В мережі $days дн. тому';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => 'Контактна інформація';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => 'Налаштування контактів';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => 'Телеметрія';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => 'Останній раз бачили';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => 'Очистити чат';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => 'Базовий телебачення';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle =>
|
||||||
|
'Дозволити спільний доступ до рівня заряду батареї та базової телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => 'Розташування телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle =>
|
||||||
|
'Дозволити спільне використання даних про місцеположення';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => 'Середовище телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle =>
|
||||||
|
'Дозволити спільний доступ до даних датчиків середовища';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => 'Канали';
|
String get channels_title => 'Канали';
|
||||||
|
|
||||||
@@ -1465,6 +1593,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Поділитися маркером тут';
|
String get map_shareMarkerHere => 'Поділитися маркером тут';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Мітка піна';
|
String get map_pinLabel => 'Мітка піна';
|
||||||
|
|
||||||
@@ -1529,6 +1660,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Показати місцезнаходження передбачених вузлів';
|
'Показати місцезнаходження передбачених вузлів';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Визначено місцезнаходження';
|
String get map_guessedLocation => 'Визначено місцезнаходження';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,49 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get connectionChoiceBluetoothLabel => '蓝牙';
|
String get connectionChoiceBluetoothLabel => '蓝牙';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => '通过 TCP 连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP地址';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => '端口';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => '输入目标地址,然后连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return '连接到 $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => '需要提供IP地址。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => '端口号必须在 1 到 65535 之间。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported => '此平台不支持 TCP 传输。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'TCP 连接超时。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'TCP 连接失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get usbScreenTitle => '通过USB连接';
|
String get usbScreenTitle => '通过USB连接';
|
||||||
|
|
||||||
@@ -331,6 +374,47 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => '隐私模式已关闭';
|
String get settings_privacyModeDisabled => '隐私模式已关闭';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => '隐私设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => '控制要共享的信息。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription => '选择您的设备与他人共享的信息。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => '拒绝所有';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => '按联系人标志允许';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => '允许全部';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => '遥测基础模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => '遥测位置模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => '遥测环境模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => '广告位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => '在广告中包含位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return '多重ACK:$value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => '遥测模式已更新';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => '操作';
|
String get settings_actions => '操作';
|
||||||
|
|
||||||
@@ -802,6 +886,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get contacts_groupNameRequired => '请输入群聊名称';
|
String get contacts_groupNameRequired => '请输入群聊名称';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contacts_groupNameReserved => '该群组名称已被保留';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_groupAlreadyExists(String name) {
|
String contacts_groupAlreadyExists(String name) {
|
||||||
return '名为 \"$name\" 的群聊已存在';
|
return '名为 \"$name\" 的群聊已存在';
|
||||||
@@ -840,6 +927,39 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '最后在线 $days 天前';
|
return '最后在线 $days 天前';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_info => '联系信息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_settings => '联系人设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_telemetry => '遥测数据';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_lastSeen => '最近出现';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_clearChat => '清除聊天记录';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBase => '遥测基站';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleBaseSubtitle => '允许共享电池电量和基本遥测数据';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLoc => '遥测位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleLocSubtitle => '允许共享位置数据';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnv => '遥测环境';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contact_teleEnvSubtitle => '允许共享环境传感器数据';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channels_title => '频道';
|
String get channels_title => '频道';
|
||||||
|
|
||||||
@@ -1378,6 +1498,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => '在此分享标记';
|
String get map_shareMarkerHere => '在此分享标记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => '设置为我的位置';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => '标签';
|
String get map_pinLabel => '标签';
|
||||||
|
|
||||||
@@ -1440,6 +1563,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_showGuessedLocations => '显示猜测的节点位置';
|
String get map_showGuessedLocations => '显示猜测的节点位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => '显示发现联系人';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => '猜测的位置';
|
String get map_guessedLocation => '猜测的位置';
|
||||||
|
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nieuwe Groep",
|
"contacts_newGroup": "Nieuwe Groep",
|
||||||
"contacts_groupName": "Groepnaam",
|
"contacts_groupName": "Groepnaam",
|
||||||
"contacts_groupNameRequired": "De groepnaam is verplicht.",
|
"contacts_groupNameRequired": "De groepnaam is verplicht.",
|
||||||
|
"contacts_groupNameReserved": "Deze groepsnaam is gereserveerd",
|
||||||
"contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.",
|
"contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbStatus_notConnected": "Selecteer een USB-apparaat",
|
"usbStatus_notConnected": "Selecteer een USB-apparaat",
|
||||||
"usbStatus_connecting": "Verbinding maken met USB-apparaat...",
|
"usbStatus_connecting": "Verbinding maken met USB-apparaat...",
|
||||||
"usbStatus_searching": "Zoeken naar USB-apparaten...",
|
"usbStatus_searching": "Zoeken naar USB-apparaten...",
|
||||||
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft."
|
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpScreenTitle": "Verbind via TCP",
|
||||||
|
"tcpHostLabel": "IP-adres",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpPortLabel": "Poort",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Voer het eindpunt in en verbind",
|
||||||
|
"tcpStatus_connectingTo": "Verbinding maken met {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Een IP-adres is vereist.",
|
||||||
|
"tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.",
|
||||||
|
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
|
||||||
|
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
||||||
|
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
|
||||||
|
"map_setAsMyLocation": "Stel dit in als mijn locatie",
|
||||||
|
"settings_privacy": "Privacyinstellingen",
|
||||||
|
"settings_privacySubtitle": "Beheer welke informatie wordt gedeeld",
|
||||||
|
"settings_telemetryLocationMode": "Telemetrie-locatiemodus",
|
||||||
|
"settings_telemetryEnvironmentMode": "Telemetrie-omgevingsmodus",
|
||||||
|
"settings_advertLocation": "Advertentielocatie",
|
||||||
|
"settings_advertLocationSubtitle": "Locatie opnemen in advertentie",
|
||||||
|
"settings_privacySettingsDescription": "Kies welke informatie uw apparaat deelt met anderen",
|
||||||
|
"settings_allowByContact": "Toestaan op basis van contactvlaggen",
|
||||||
|
"settings_allowAll": "Alles toestaan",
|
||||||
|
"settings_denyAll": "Weiger alles",
|
||||||
|
"contact_info": "Contactinformatie",
|
||||||
|
"settings_telemetryBaseMode": "Telemetrie-basismodus",
|
||||||
|
"contact_teleBase": "Telemetrie_basis",
|
||||||
|
"contact_teleLoc": "Telemetrielocatie",
|
||||||
|
"contact_teleLocSubtitle": "Locatiegegevens delen toestaan",
|
||||||
|
"contact_teleEnv": "Telemetrieomgeving",
|
||||||
|
"contact_teleEnvSubtitle": "Delen van omgevingsensordata toestaan",
|
||||||
|
"contact_settings": "Contactinstellingen",
|
||||||
|
"contact_telemetry": "Telemetrie",
|
||||||
|
"contact_lastSeen": "Laatst gezien",
|
||||||
|
"contact_clearChat": "Chat leegmaken",
|
||||||
|
"contact_teleBaseSubtitle": "Sta delen van batterij niveau en basis telemetrie toe",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Telemetrie-modus bijgewerkt",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nowa Grupa",
|
"contacts_newGroup": "Nowa Grupa",
|
||||||
"contacts_groupName": "Nazwa grupy",
|
"contacts_groupName": "Nazwa grupy",
|
||||||
"contacts_groupNameRequired": "Nazwa grupy jest wymagana",
|
"contacts_groupNameRequired": "Nazwa grupy jest wymagana",
|
||||||
|
"contacts_groupNameReserved": "Ta nazwa grupy jest zastrzeżona",
|
||||||
"contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje",
|
"contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbStatus_connecting": "Połączenie z urządzeniem USB...",
|
"usbStatus_connecting": "Połączenie z urządzeniem USB...",
|
||||||
"usbStatus_notConnected": "Wybierz urządzenie USB",
|
"usbStatus_notConnected": "Wybierz urządzenie USB",
|
||||||
"usbConnectionFailed": "Błąd połączenia USB: {error}",
|
"usbConnectionFailed": "Błąd połączenia USB: {error}",
|
||||||
"usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"."
|
"usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "Połącz się za pomocą protokołu TCP",
|
||||||
|
"tcpHostLabel": "Adres IP",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Wprowadź adres URL i połącz",
|
||||||
|
"tcpStatus_connectingTo": "Połączenie z {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Wymagana jest adresa IP.",
|
||||||
|
"tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.",
|
||||||
|
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
|
||||||
|
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
||||||
|
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
|
||||||
|
"map_setAsMyLocation": "Ustaw jako moje lokalizację",
|
||||||
|
"settings_allowByContact": "Zezwalaj według flag kontaktowych",
|
||||||
|
"settings_allowAll": "Zezwalaj na wszystko",
|
||||||
|
"settings_telemetryLocationMode": "Tryb położenia telemetrycznego",
|
||||||
|
"settings_telemetryEnvironmentMode": "Tryb środowiska telemetrycznego",
|
||||||
|
"settings_advertLocation": "Lokalizacja reklamowa",
|
||||||
|
"settings_advertLocationSubtitle": "Uwzględnij lokalizację w ogłoszeniu",
|
||||||
|
"settings_denyAll": "Odmów wszystkim",
|
||||||
|
"settings_privacySubtitle": "Kontroluj jakie informacje są udostępniane.",
|
||||||
|
"settings_privacy": "Ustawienia prywatności",
|
||||||
|
"settings_privacySettingsDescription": "Wybierz jakie informacje urządzenie udostępni innym.",
|
||||||
|
"contact_info": "Informacje kontaktowe",
|
||||||
|
"settings_telemetryBaseMode": "Tryb podstawowy telemetrii",
|
||||||
|
"contact_teleBase": "Baza telemetryczna",
|
||||||
|
"contact_teleLoc": "Lokalizacja telemetryczna",
|
||||||
|
"contact_teleLocSubtitle": "Zezwalaj na udostępnianie danych lokalizacji",
|
||||||
|
"contact_teleEnv": "Środowisko telemetryczne",
|
||||||
|
"contact_teleEnvSubtitle": "Zezwalaj na udostępnianie danych czujników środowiskowych",
|
||||||
|
"contact_telemetry": "Telemetryka",
|
||||||
|
"contact_clearChat": "Wyczyść czat",
|
||||||
|
"contact_settings": "Ustawienia kontaktowe",
|
||||||
|
"contact_lastSeen": "Ostatnio widziany",
|
||||||
|
"contact_teleBaseSubtitle": "Pozwól na udostępnianie poziomu naładowania baterii i podstawowych danych telemetrycznych",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Tryb telemetryczny zaktualizowany",
|
||||||
|
"settings_multiAck": "Wiele potwierdzeń: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Novo Grupo",
|
"contacts_newGroup": "Novo Grupo",
|
||||||
"contacts_groupName": "Nome do grupo",
|
"contacts_groupName": "Nome do grupo",
|
||||||
"contacts_groupNameRequired": "O nome do grupo é obrigatório.",
|
"contacts_groupNameRequired": "O nome do grupo é obrigatório.",
|
||||||
|
"contacts_groupNameReserved": "Este nome de grupo está reservado",
|
||||||
"contacts_groupAlreadyExists": "O grupo \"{name}\" já existe",
|
"contacts_groupAlreadyExists": "O grupo \"{name}\" já existe",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbStatus_notConnected": "Selecione um dispositivo USB",
|
"usbStatus_notConnected": "Selecione um dispositivo USB",
|
||||||
"usbConnectionFailed": "Falha na conexão USB: {error}",
|
"usbConnectionFailed": "Falha na conexão USB: {error}",
|
||||||
"usbStatus_connecting": "Conectando ao dispositivo USB...",
|
"usbStatus_connecting": "Conectando ao dispositivo USB...",
|
||||||
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion."
|
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "Endereço IP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpScreenTitle": "Estabelecer conexão via TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpPortLabel": "Porta",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Insira o endereço final e conecte-se.",
|
||||||
|
"tcpStatus_connectingTo": "Conectando a {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "É necessário fornecer um endereço IP.",
|
||||||
|
"tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.",
|
||||||
|
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
|
||||||
|
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
||||||
|
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
|
||||||
|
"map_setAsMyLocation": "Defina minha localização",
|
||||||
|
"settings_privacySettingsDescription": "Escolha quais informações o seu dispositivo compartilha com os outros.",
|
||||||
|
"settings_allowByContact": "Permitir por bandeiras de contato",
|
||||||
|
"settings_telemetryLocationMode": "Modo de Localização de Telemetria",
|
||||||
|
"settings_telemetryEnvironmentMode": "Modo de Ambiente de Telemetria",
|
||||||
|
"settings_advertLocation": "Localização do Anúncio",
|
||||||
|
"settings_advertLocationSubtitle": "Incluir localização no anúncio",
|
||||||
|
"settings_privacySubtitle": "Controle o que é compartilhado.",
|
||||||
|
"settings_denyAll": "Negar todos",
|
||||||
|
"settings_allowAll": "Permitir todos",
|
||||||
|
"settings_privacy": "Configurações de Privacidade",
|
||||||
|
"contact_info": "Informações de Contato",
|
||||||
|
"settings_telemetryBaseMode": "Modo Base de Telemetria",
|
||||||
|
"contact_teleBase": "Base de Telemetria",
|
||||||
|
"contact_teleLoc": "Localização de Telemetria",
|
||||||
|
"contact_teleLocSubtitle": "Permitir compartilhamento de dados de localização",
|
||||||
|
"contact_teleEnv": "Ambiente de Telemetria",
|
||||||
|
"contact_teleEnvSubtitle": "Permitir compartilhamento de dados do sensor de ambiente",
|
||||||
|
"contact_lastSeen": "Visto pela última vez",
|
||||||
|
"contact_clearChat": "Limpar Chat",
|
||||||
|
"contact_telemetry": "Telemetria",
|
||||||
|
"contact_settings": "Configurações de Contato",
|
||||||
|
"contact_teleBaseSubtitle": "Permitir compartilhamento do nível da bateria e telemetria básica",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Modo de telemetria atualizado",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -212,6 +212,7 @@
|
|||||||
"contacts_newGroup": "Новая группа",
|
"contacts_newGroup": "Новая группа",
|
||||||
"contacts_groupName": "Имя группы",
|
"contacts_groupName": "Имя группы",
|
||||||
"contacts_groupNameRequired": "Имя группы обязательно",
|
"contacts_groupNameRequired": "Имя группы обязательно",
|
||||||
|
"contacts_groupNameReserved": "Это имя группы зарезервировано",
|
||||||
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
|
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
|
||||||
"contacts_filterContacts": "Фильтр контактов...",
|
"contacts_filterContacts": "Фильтр контактов...",
|
||||||
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
|
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
|
||||||
@@ -1099,5 +1100,65 @@
|
|||||||
"usbStatus_connecting": "Подключение к USB-устройству...",
|
"usbStatus_connecting": "Подключение к USB-устройству...",
|
||||||
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
|
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
|
||||||
"usbStatus_notConnected": "Выберите USB-устройство",
|
"usbStatus_notConnected": "Выберите USB-устройство",
|
||||||
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion."
|
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostLabel": "IP-адрес",
|
||||||
|
"tcpScreenTitle": "Установить соединение по протоколу TCP",
|
||||||
|
"tcpPortLabel": "Порт",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Введите адрес и подключитесь.",
|
||||||
|
"tcpStatus_connectingTo": "Подключение к {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Необходимо указать IP-адрес.",
|
||||||
|
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
|
||||||
|
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
|
||||||
|
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
||||||
|
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Показать контакты Discovery",
|
||||||
|
"map_setAsMyLocation": "Установить мое местоположение",
|
||||||
|
"settings_privacy": "Настройки конфиденциальности",
|
||||||
|
"settings_privacySubtitle": "Контролируйте, какую информацию делиться.",
|
||||||
|
"settings_telemetryLocationMode": "Режим местоположения телеметрии",
|
||||||
|
"settings_telemetryEnvironmentMode": "Режим среды телеметрии",
|
||||||
|
"settings_advertLocation": "Местоположение рекламы",
|
||||||
|
"settings_advertLocationSubtitle": "Включить местоположение в объявление",
|
||||||
|
"settings_allowAll": "Разрешить все",
|
||||||
|
"settings_privacySettingsDescription": "Выберите, какую информацию ваше устройство будет делиться с другими.",
|
||||||
|
"settings_denyAll": "Отклонить все",
|
||||||
|
"settings_allowByContact": "Разрешить по флагам контактов",
|
||||||
|
"contact_info": "Контактная информация",
|
||||||
|
"settings_telemetryBaseMode": "Базовый режим телеметрии",
|
||||||
|
"contact_teleBase": "База телеметрии",
|
||||||
|
"contact_teleLoc": "Местоположение телеметрии",
|
||||||
|
"contact_teleLocSubtitle": "Разрешить обмен данными о местоположении",
|
||||||
|
"contact_teleEnv": "Среда телеметрии",
|
||||||
|
"contact_teleEnvSubtitle": "Разрешить обмен данными датчиков окружающей среды",
|
||||||
|
"contact_settings": "Настройки контактов",
|
||||||
|
"contact_telemetry": "Телеметрия",
|
||||||
|
"contact_clearChat": "Очистить чат",
|
||||||
|
"contact_lastSeen": "Последний раз видели",
|
||||||
|
"contact_teleBaseSubtitle": "Разрешить обмен уровнем заряда батареи и базовой телеметрией",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
|
||||||
|
"settings_multiAck": "Мульти-ACK: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nová skupina",
|
"contacts_newGroup": "Nová skupina",
|
||||||
"contacts_groupName": "Názov skupiny",
|
"contacts_groupName": "Názov skupiny",
|
||||||
"contacts_groupNameRequired": "Skupina musí mať názov.",
|
"contacts_groupNameRequired": "Skupina musí mať názov.",
|
||||||
|
"contacts_groupNameReserved": "Tento názov skupiny je rezervovaný",
|
||||||
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
|
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
|
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
|
||||||
"usbStatus_notConnected": "Vyberte USB zariadenie",
|
"usbStatus_notConnected": "Vyberte USB zariadenie",
|
||||||
"usbStatus_connecting": "Pripojenie k USB zariadeniu...",
|
"usbStatus_connecting": "Pripojenie k USB zariadeniu...",
|
||||||
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion."
|
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpHostLabel": "IP adresa",
|
||||||
|
"tcpScreenTitle": "Spojte sa pomocou protokolu TCP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.",
|
||||||
|
"tcpStatus_connectingTo": "Pripojenie k {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Je potrebné zadať IP adresu.",
|
||||||
|
"tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.",
|
||||||
|
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
|
||||||
|
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
||||||
|
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
|
||||||
|
"map_setAsMyLocation": "Nastavte ako moju polohu",
|
||||||
|
"settings_privacy": "Nastavenia súkromia",
|
||||||
|
"settings_privacySubtitle": "Ovládni, aké informácie sa zdieľajú.",
|
||||||
|
"settings_telemetryLocationMode": "Režim umiestnenia telemetrie",
|
||||||
|
"settings_telemetryBaseMode": "Základný režim telemetrie",
|
||||||
|
"settings_advertLocation": "Umiestnenie inzerátu",
|
||||||
|
"settings_telemetryEnvironmentMode": "Režim prostredia telemetrie",
|
||||||
|
"settings_advertLocationSubtitle": "Zahrnúť polohu do inzerátu",
|
||||||
|
"settings_allowAll": "Povoliť všetko",
|
||||||
|
"settings_privacySettingsDescription": "Vyberte, ktoré informácie váš zariadenie zdieľa s ostatnými.",
|
||||||
|
"settings_denyAll": "Zamietnuť všetko",
|
||||||
|
"settings_allowByContact": "Povoliť podľa kontaktových vlajok",
|
||||||
|
"contact_info": "Kontaktné informácie",
|
||||||
|
"contact_settings": "Nastavenia kontaktov",
|
||||||
|
"contact_teleBaseSubtitle": "Povoliť zdieľanie úrovne batérie a základnej telemetrie",
|
||||||
|
"contact_teleLoc": "Lokácia telemetrie",
|
||||||
|
"contact_teleLocSubtitle": "Povoliť zdieľanie údajov o lokalite",
|
||||||
|
"contact_teleEnv": "Prostredie telemetrie",
|
||||||
|
"contact_telemetry": "Telemetria",
|
||||||
|
"contact_clearChat": "Vymazať chat",
|
||||||
|
"contact_lastSeen": "Naposledy videný",
|
||||||
|
"contact_teleBase": "Báza telemetrie",
|
||||||
|
"contact_teleEnvSubtitle": "Povoliť zdieľanie údajov senzorov prostredia",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Režim telemetrie bol aktualizovaný",
|
||||||
|
"settings_multiAck": "Viaceré ACK: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Nova skupina",
|
"contacts_newGroup": "Nova skupina",
|
||||||
"contacts_groupName": "Ime skupine",
|
"contacts_groupName": "Ime skupine",
|
||||||
"contacts_groupNameRequired": "Ime skupine je obvezno.",
|
"contacts_groupNameRequired": "Ime skupine je obvezno.",
|
||||||
|
"contacts_groupNameReserved": "To ime skupine je rezervirano",
|
||||||
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
|
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbStatus_connecting": "Povezava z USB napravo...",
|
"usbStatus_connecting": "Povezava z USB napravo...",
|
||||||
"usbStatus_searching": "Iskanje USB naprav...",
|
"usbStatus_searching": "Iskanje USB naprav...",
|
||||||
"usbConnectionFailed": "Napaka pri povezavi preko USB: {error}",
|
"usbConnectionFailed": "Napaka pri povezavi preko USB: {error}",
|
||||||
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion."
|
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostLabel": "IP naslov",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "Komunicirajte preko protokola TCP",
|
||||||
|
"tcpPortLabel": "Vrata",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Vnesite končni naslov in se povežite",
|
||||||
|
"tcpStatus_connectingTo": "Povezava z {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Potrebna je IP-naslov.",
|
||||||
|
"tcpErrorPortInvalid": "Port mora biti med 1 in 65535.",
|
||||||
|
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
|
||||||
|
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
||||||
|
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
|
||||||
|
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo",
|
||||||
|
"settings_privacy": "Nastavitve zasebnosti",
|
||||||
|
"settings_privacySettingsDescription": "Izberite, katere informacije vaš naprava deli z drugimi.",
|
||||||
|
"settings_telemetryBaseMode": "Osnovni način telemetrije",
|
||||||
|
"settings_telemetryLocationMode": "Način delovanja telemetrije",
|
||||||
|
"settings_telemetryEnvironmentMode": "Način delovanja okolja telemetrije",
|
||||||
|
"settings_advertLocation": "Lokacija oglasa",
|
||||||
|
"settings_allowByContact": "Dovoli po kontaktnih zastavah",
|
||||||
|
"settings_denyAll": "Zavrniti vse",
|
||||||
|
"settings_allowAll": "Dovoli vse",
|
||||||
|
"settings_privacySubtitle": "Kontrolirajte, katere informacije so deljene.",
|
||||||
|
"contact_info": "Kontaktni podatki",
|
||||||
|
"contact_teleBase": "Baza telemetrije",
|
||||||
|
"contact_teleBaseSubtitle": "Dovoli deljenje stanja baterije in osnovne telemetrije",
|
||||||
|
"contact_teleLoc": "Lokacija telemetrije",
|
||||||
|
"contact_lastSeen": "Zadnjič videno",
|
||||||
|
"contact_settings": "Nastavitve stika",
|
||||||
|
"settings_advertLocationSubtitle": "Vključi lokacijo v oglas.",
|
||||||
|
"contact_telemetry": "Telemetrija",
|
||||||
|
"contact_clearChat": "Počisti klepet",
|
||||||
|
"contact_teleEnv": "Okolje telemetrije",
|
||||||
|
"contact_teleEnvSubtitle": "Dovoli deljenje podatkov okoljskih senzorjev",
|
||||||
|
"contact_teleLocSubtitle": "Dovoli deljenje podatkov o lokaciji",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_multiAck": "Večkratni potrditvi: {value}",
|
||||||
|
"settings_telemetryModeUpdated": "Način telemetrije posodobljen"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -285,6 +285,7 @@
|
|||||||
"contacts_newGroup": "Ny grupp",
|
"contacts_newGroup": "Ny grupp",
|
||||||
"contacts_groupName": "Gruppnamn",
|
"contacts_groupName": "Gruppnamn",
|
||||||
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
|
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
|
||||||
|
"contacts_groupNameReserved": "Detta gruppnamn är reserverat",
|
||||||
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
|
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbStatus_notConnected": "Välj en USB-enhet",
|
"usbStatus_notConnected": "Välj en USB-enhet",
|
||||||
"usbConnectionFailed": "Fel vid USB-anslutning: {error}",
|
"usbConnectionFailed": "Fel vid USB-anslutning: {error}",
|
||||||
"usbStatus_searching": "Söker efter USB-enheter...",
|
"usbStatus_searching": "Söker efter USB-enheter...",
|
||||||
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware."
|
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpHostLabel": "IP-adress",
|
||||||
|
"tcpScreenTitle": "Anslut via TCP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Ange slutpunkt och anslut",
|
||||||
|
"tcpStatus_connectingTo": "Anslutning till {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "IP-adress krävs.",
|
||||||
|
"tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.",
|
||||||
|
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
|
||||||
|
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
||||||
|
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
|
||||||
|
"map_setAsMyLocation": "Ange som min plats",
|
||||||
|
"settings_privacy": "Inställningar för sekretess",
|
||||||
|
"settings_allowAll": "Tillåt alla",
|
||||||
|
"settings_privacySubtitle": "Kontrollera vilken information som delas.",
|
||||||
|
"settings_telemetryEnvironmentMode": "Telemetri miljöläge",
|
||||||
|
"settings_telemetryBaseMode": "Telemetribasläge",
|
||||||
|
"settings_telemetryLocationMode": "Telemetritillstånd för plats",
|
||||||
|
"settings_advertLocation": "Annonsplacering",
|
||||||
|
"contact_info": "Kontaktinformation",
|
||||||
|
"contact_settings": "Kontaktinställningar",
|
||||||
|
"contact_telemetry": "Telemetri",
|
||||||
|
"settings_denyAll": "Neka alla",
|
||||||
|
"settings_allowByContact": "Tillåt via kontaktflaggor",
|
||||||
|
"settings_privacySettingsDescription": "Välj vilken information din enhet delar med andra.",
|
||||||
|
"contact_lastSeen": "Senast sedd",
|
||||||
|
"contact_clearChat": "Rensa Chatt",
|
||||||
|
"contact_teleEnv": "Telemetri Miljö",
|
||||||
|
"settings_advertLocationSubtitle": "Inkludera plats i annonsen",
|
||||||
|
"contact_teleEnvSubtitle": "Tillåt delning av miljösensordata",
|
||||||
|
"contact_teleBase": "Telemetribas",
|
||||||
|
"contact_teleBaseSubtitle": "Tillåt delning av batterinivå och grundläggande telemetri",
|
||||||
|
"contact_teleLoc": "Telemetridata plats",
|
||||||
|
"contact_teleLocSubtitle": "Tillåt delning av platsdata",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Telemetri-läge uppdaterat",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -286,6 +286,7 @@
|
|||||||
"contacts_newGroup": "Нова група",
|
"contacts_newGroup": "Нова група",
|
||||||
"contacts_groupName": "Назва групи",
|
"contacts_groupName": "Назва групи",
|
||||||
"contacts_groupNameRequired": "Назва групи обов'язкова.",
|
"contacts_groupNameRequired": "Назва групи обов'язкова.",
|
||||||
|
"contacts_groupNameReserved": "Ця назва групи зарезервована",
|
||||||
"contacts_groupAlreadyExists": "Група «{name}» вже існує.",
|
"contacts_groupAlreadyExists": "Група «{name}» вже існує.",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1859,5 +1860,65 @@
|
|||||||
"usbStatus_notConnected": "Виберіть пристрій USB",
|
"usbStatus_notConnected": "Виберіть пристрій USB",
|
||||||
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
|
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
|
||||||
"usbStatus_connecting": "Підключення до USB-пристрою...",
|
"usbStatus_connecting": "Підключення до USB-пристрою...",
|
||||||
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion."
|
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpHostLabel": "IP-адреса",
|
||||||
|
"tcpScreenTitle": "З'єднатися через протокол TCP",
|
||||||
|
"tcpPortLabel": "Порт",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Введіть кінцеву точку та підключіться",
|
||||||
|
"tcpStatus_connectingTo": "Підключення до {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Необхідно вказати IP-адресу.",
|
||||||
|
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
|
||||||
|
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
|
||||||
|
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
||||||
|
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Показати контакти Відкриття",
|
||||||
|
"map_setAsMyLocation": "Встановити моє місцезнаходження",
|
||||||
|
"settings_privacySubtitle": "Керуйте інформацією, яку буде спільно використовуватися",
|
||||||
|
"settings_privacy": "Налаштування приватності",
|
||||||
|
"settings_telemetryBaseMode": "Режим базової телеметрії",
|
||||||
|
"settings_telemetryLocationMode": "Режим місця телеметрії",
|
||||||
|
"settings_advertLocation": "Розміщення реклами",
|
||||||
|
"settings_advertLocationSubtitle": "Включити місце розташування в оголошення",
|
||||||
|
"settings_privacySettingsDescription": "Виберіть, яку інформацію ваш пристрій буде передавати іншим.",
|
||||||
|
"settings_allowAll": "Дозволити все",
|
||||||
|
"settings_denyAll": "Відхилити все",
|
||||||
|
"settings_allowByContact": "Дозволити за контактними прапорцями",
|
||||||
|
"settings_telemetryEnvironmentMode": "Режим середовища телеметрії",
|
||||||
|
"contact_info": "Контактна інформація",
|
||||||
|
"contact_teleBaseSubtitle": "Дозволити спільний доступ до рівня заряду батареї та базової телеметрії",
|
||||||
|
"contact_teleLoc": "Розташування телеметрії",
|
||||||
|
"contact_teleBase": "Базовий телебачення",
|
||||||
|
"contact_teleLocSubtitle": "Дозволити спільне використання даних про місцеположення",
|
||||||
|
"contact_settings": "Налаштування контактів",
|
||||||
|
"contact_telemetry": "Телеметрія",
|
||||||
|
"contact_clearChat": "Очистити чат",
|
||||||
|
"contact_lastSeen": "Останній раз бачили",
|
||||||
|
"contact_teleEnv": "Середовище телеметрії",
|
||||||
|
"contact_teleEnvSubtitle": "Дозволити спільний доступ до даних датчиків середовища",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Режим телеметрії оновлено",
|
||||||
|
"settings_multiAck": "Багатократне підтвердження: {value}"
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -300,6 +300,7 @@
|
|||||||
"contacts_newGroup": "新建群聊",
|
"contacts_newGroup": "新建群聊",
|
||||||
"contacts_groupName": "群聊名称",
|
"contacts_groupName": "群聊名称",
|
||||||
"contacts_groupNameRequired": "请输入群聊名称",
|
"contacts_groupNameRequired": "请输入群聊名称",
|
||||||
|
"contacts_groupNameReserved": "该群组名称已被保留",
|
||||||
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
|
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
|
||||||
"@contacts_groupAlreadyExists": {
|
"@contacts_groupAlreadyExists": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1864,5 +1865,65 @@
|
|||||||
"usbStatus_connecting": "连接USB设备...",
|
"usbStatus_connecting": "连接USB设备...",
|
||||||
"usbStatus_notConnected": "选择一个 USB 设备",
|
"usbStatus_notConnected": "选择一个 USB 设备",
|
||||||
"usbConnectionFailed": "USB 连接失败:{error}",
|
"usbConnectionFailed": "USB 连接失败:{error}",
|
||||||
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。"
|
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "IP地址",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "通过 TCP 连接",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpPortLabel": "端口",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "输入目标地址,然后连接",
|
||||||
|
"tcpStatus_connectingTo": "连接到 {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "需要提供IP地址。",
|
||||||
|
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
|
||||||
|
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
|
||||||
|
"tcpErrorTimedOut": "TCP 连接超时。",
|
||||||
|
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
||||||
|
"map_showDiscoveryContacts": "显示发现联系人",
|
||||||
|
"map_setAsMyLocation": "设置为我的位置",
|
||||||
|
"settings_privacySubtitle": "控制要共享的信息。",
|
||||||
|
"settings_privacySettingsDescription": "选择您的设备与他人共享的信息。",
|
||||||
|
"settings_telemetryBaseMode": "遥测基础模式",
|
||||||
|
"settings_telemetryLocationMode": "遥测位置模式",
|
||||||
|
"settings_advertLocation": "广告位置",
|
||||||
|
"settings_advertLocationSubtitle": "在广告中包含位置",
|
||||||
|
"settings_allowByContact": "按联系人标志允许",
|
||||||
|
"settings_denyAll": "拒绝所有",
|
||||||
|
"settings_privacy": "隐私设置",
|
||||||
|
"settings_allowAll": "允许全部",
|
||||||
|
"contact_info": "联系信息",
|
||||||
|
"contact_teleBase": "遥测基站",
|
||||||
|
"contact_teleBaseSubtitle": "允许共享电池电量和基本遥测数据",
|
||||||
|
"settings_telemetryEnvironmentMode": "遥测环境模式",
|
||||||
|
"contact_teleLoc": "遥测位置",
|
||||||
|
"contact_teleEnv": "遥测环境",
|
||||||
|
"contact_teleEnvSubtitle": "允许共享环境传感器数据",
|
||||||
|
"contact_clearChat": "清除聊天记录",
|
||||||
|
"contact_lastSeen": "最近出现",
|
||||||
|
"contact_settings": "联系人设置",
|
||||||
|
"contact_teleLocSubtitle": "允许共享位置数据",
|
||||||
|
"contact_telemetry": "遥测数据",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_multiAck": "多重ACK:{value}",
|
||||||
|
"settings_telemetryModeUpdated": "遥测模式已更新"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import 'services/app_debug_log_service.dart';
|
|||||||
import 'services/background_service.dart';
|
import 'services/background_service.dart';
|
||||||
import 'services/map_tile_cache_service.dart';
|
import 'services/map_tile_cache_service.dart';
|
||||||
import 'services/chat_text_scale_service.dart';
|
import 'services/chat_text_scale_service.dart';
|
||||||
|
import 'services/ui_view_state_service.dart';
|
||||||
|
import 'services/timeout_prediction_service.dart';
|
||||||
import 'storage/prefs_manager.dart';
|
import 'storage/prefs_manager.dart';
|
||||||
import 'utils/app_logger.dart';
|
import 'utils/app_logger.dart';
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@ void main() async {
|
|||||||
final backgroundService = BackgroundService();
|
final backgroundService = BackgroundService();
|
||||||
final mapTileCacheService = MapTileCacheService();
|
final mapTileCacheService = MapTileCacheService();
|
||||||
final chatTextScaleService = ChatTextScaleService();
|
final chatTextScaleService = ChatTextScaleService();
|
||||||
|
final uiViewStateService = UiViewStateService();
|
||||||
|
final timeoutPredictionService = TimeoutPredictionService(storage);
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
await appSettingsService.loadSettings();
|
await appSettingsService.loadSettings();
|
||||||
@@ -56,6 +60,8 @@ void main() async {
|
|||||||
_registerThirdPartyLicenses();
|
_registerThirdPartyLicenses();
|
||||||
|
|
||||||
await chatTextScaleService.initialize();
|
await chatTextScaleService.initialize();
|
||||||
|
await uiViewStateService.initialize();
|
||||||
|
await timeoutPredictionService.initialize();
|
||||||
|
|
||||||
// Wire up connector with services
|
// Wire up connector with services
|
||||||
connector.initialize(
|
connector.initialize(
|
||||||
@@ -65,6 +71,7 @@ void main() async {
|
|||||||
bleDebugLogService: bleDebugLogService,
|
bleDebugLogService: bleDebugLogService,
|
||||||
appDebugLogService: appDebugLogService,
|
appDebugLogService: appDebugLogService,
|
||||||
backgroundService: backgroundService,
|
backgroundService: backgroundService,
|
||||||
|
timeoutPredictionService: timeoutPredictionService,
|
||||||
);
|
);
|
||||||
|
|
||||||
await connector.loadContactCache();
|
await connector.loadContactCache();
|
||||||
@@ -86,6 +93,8 @@ void main() async {
|
|||||||
appDebugLogService: appDebugLogService,
|
appDebugLogService: appDebugLogService,
|
||||||
mapTileCacheService: mapTileCacheService,
|
mapTileCacheService: mapTileCacheService,
|
||||||
chatTextScaleService: chatTextScaleService,
|
chatTextScaleService: chatTextScaleService,
|
||||||
|
uiViewStateService: uiViewStateService,
|
||||||
|
timeoutPredictionService: timeoutPredictionService,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,6 +130,8 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
final AppDebugLogService appDebugLogService;
|
final AppDebugLogService appDebugLogService;
|
||||||
final MapTileCacheService mapTileCacheService;
|
final MapTileCacheService mapTileCacheService;
|
||||||
final ChatTextScaleService chatTextScaleService;
|
final ChatTextScaleService chatTextScaleService;
|
||||||
|
final UiViewStateService uiViewStateService;
|
||||||
|
final TimeoutPredictionService timeoutPredictionService;
|
||||||
|
|
||||||
const MeshCoreApp({
|
const MeshCoreApp({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -133,6 +144,8 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
required this.appDebugLogService,
|
required this.appDebugLogService,
|
||||||
required this.mapTileCacheService,
|
required this.mapTileCacheService,
|
||||||
required this.chatTextScaleService,
|
required this.chatTextScaleService,
|
||||||
|
required this.uiViewStateService,
|
||||||
|
required this.timeoutPredictionService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -146,8 +159,10 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||||
ChangeNotifierProvider.value(value: appDebugLogService),
|
ChangeNotifierProvider.value(value: appDebugLogService),
|
||||||
ChangeNotifierProvider.value(value: chatTextScaleService),
|
ChangeNotifierProvider.value(value: chatTextScaleService),
|
||||||
|
ChangeNotifierProvider.value(value: uiViewStateService),
|
||||||
Provider.value(value: storage),
|
Provider.value(value: storage),
|
||||||
Provider.value(value: mapTileCacheService),
|
Provider.value(value: mapTileCacheService),
|
||||||
|
ChangeNotifierProvider.value(value: timeoutPredictionService),
|
||||||
],
|
],
|
||||||
child: Consumer<AppSettingsService>(
|
child: Consumer<AppSettingsService>(
|
||||||
builder: (context, settingsService, child) {
|
builder: (context, settingsService, child) {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class AppSettings {
|
|||||||
final Map<String, String> batteryChemistryByRepeaterId;
|
final Map<String, String> batteryChemistryByRepeaterId;
|
||||||
final UnitSystem unitSystem;
|
final UnitSystem unitSystem;
|
||||||
final Set<String> mutedChannels;
|
final Set<String> mutedChannels;
|
||||||
|
final bool mapShowDiscoveryContacts;
|
||||||
|
final String tcpServerAddress;
|
||||||
|
final int tcpServerPort;
|
||||||
|
|
||||||
AppSettings({
|
AppSettings({
|
||||||
this.clearPathOnMaxRetry = false,
|
this.clearPathOnMaxRetry = false,
|
||||||
@@ -66,6 +69,9 @@ class AppSettings {
|
|||||||
Map<String, String>? batteryChemistryByRepeaterId,
|
Map<String, String>? batteryChemistryByRepeaterId,
|
||||||
this.unitSystem = UnitSystem.metric,
|
this.unitSystem = UnitSystem.metric,
|
||||||
Set<String>? mutedChannels,
|
Set<String>? mutedChannels,
|
||||||
|
this.mapShowDiscoveryContacts = true,
|
||||||
|
this.tcpServerAddress = '',
|
||||||
|
this.tcpServerPort = 0,
|
||||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
||||||
mutedChannels = mutedChannels ?? {};
|
mutedChannels = mutedChannels ?? {};
|
||||||
@@ -97,6 +103,9 @@ class AppSettings {
|
|||||||
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
|
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
|
||||||
'unit_system': unitSystem.value,
|
'unit_system': unitSystem.value,
|
||||||
'muted_channels': mutedChannels.toList(),
|
'muted_channels': mutedChannels.toList(),
|
||||||
|
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
||||||
|
'tcp_server_address': tcpServerAddress,
|
||||||
|
'tcp_server_port': tcpServerPort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +161,10 @@ class AppSettings {
|
|||||||
?.map((e) => e.toString())
|
?.map((e) => e.toString())
|
||||||
.toSet()) ??
|
.toSet()) ??
|
||||||
{},
|
{},
|
||||||
|
mapShowDiscoveryContacts:
|
||||||
|
json['map_show_discovery_contacts'] as bool? ?? true,
|
||||||
|
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
|
||||||
|
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +194,9 @@ class AppSettings {
|
|||||||
Map<String, String>? batteryChemistryByRepeaterId,
|
Map<String, String>? batteryChemistryByRepeaterId,
|
||||||
UnitSystem? unitSystem,
|
UnitSystem? unitSystem,
|
||||||
Set<String>? mutedChannels,
|
Set<String>? mutedChannels,
|
||||||
|
bool? mapShowDiscoveryContacts,
|
||||||
|
String? tcpServerAddress,
|
||||||
|
int? tcpServerPort,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||||
@@ -217,6 +233,10 @@ class AppSettings {
|
|||||||
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
|
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
|
||||||
unitSystem: unitSystem ?? this.unitSystem,
|
unitSystem: unitSystem ?? this.unitSystem,
|
||||||
mutedChannels: mutedChannels ?? this.mutedChannels,
|
mutedChannels: mutedChannels ?? this.mutedChannels,
|
||||||
|
mapShowDiscoveryContacts:
|
||||||
|
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
||||||
|
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
|
||||||
|
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-58
@@ -1,4 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:meshcore_open/utils/app_logger.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
class Contact {
|
class Contact {
|
||||||
@@ -15,6 +17,8 @@ class Contact {
|
|||||||
final double? longitude;
|
final double? longitude;
|
||||||
final DateTime lastSeen;
|
final DateTime lastSeen;
|
||||||
final DateTime lastMessageAt;
|
final DateTime lastMessageAt;
|
||||||
|
final bool isActive;
|
||||||
|
final Uint8List? rawPacket;
|
||||||
|
|
||||||
Contact({
|
Contact({
|
||||||
required this.publicKey,
|
required this.publicKey,
|
||||||
@@ -29,6 +33,8 @@ class Contact {
|
|||||||
this.longitude,
|
this.longitude,
|
||||||
required this.lastSeen,
|
required this.lastSeen,
|
||||||
DateTime? lastMessageAt,
|
DateTime? lastMessageAt,
|
||||||
|
this.isActive = true,
|
||||||
|
this.rawPacket,
|
||||||
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
||||||
|
|
||||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
String get publicKeyHex => pubKeyToHex(publicKey);
|
||||||
@@ -59,7 +65,17 @@ class Contact {
|
|||||||
return '$pathLength hops';
|
return '$pathLength hops';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get hasLocation => latitude != null && longitude != null;
|
bool get hasLocation {
|
||||||
|
const double epsilon = 1e-6;
|
||||||
|
final lat = latitude ?? 0.0;
|
||||||
|
final lon = longitude ?? 0.0;
|
||||||
|
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
|
||||||
|
lat >= -90.0 &&
|
||||||
|
lat <= 90.0 &&
|
||||||
|
lon >= -180.0 &&
|
||||||
|
lon <= 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
bool get isFavorite => (flags & contactFlagFavorite) != 0;
|
bool get isFavorite => (flags & contactFlagFavorite) != 0;
|
||||||
|
|
||||||
Contact copyWith({
|
Contact copyWith({
|
||||||
@@ -76,6 +92,8 @@ class Contact {
|
|||||||
double? longitude,
|
double? longitude,
|
||||||
DateTime? lastSeen,
|
DateTime? lastSeen,
|
||||||
DateTime? lastMessageAt,
|
DateTime? lastMessageAt,
|
||||||
|
bool? isActive,
|
||||||
|
Uint8List? rawPacket,
|
||||||
}) {
|
}) {
|
||||||
return Contact(
|
return Contact(
|
||||||
publicKey: publicKey ?? this.publicKey,
|
publicKey: publicKey ?? this.publicKey,
|
||||||
@@ -94,11 +112,13 @@ class Contact {
|
|||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
lastSeen: lastSeen ?? this.lastSeen,
|
lastSeen: lastSeen ?? this.lastSeen,
|
||||||
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
rawPacket: rawPacket ?? this.rawPacket,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get pathIdList {
|
String get pathIdList {
|
||||||
final pathBytes = _pathBytesForDisplay;
|
final pathBytes = pathBytesForDisplay;
|
||||||
if (pathBytes.isEmpty) return '';
|
if (pathBytes.isEmpty) return '';
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
final groupSize = pathHashSize;
|
final groupSize = pathHashSize;
|
||||||
@@ -120,43 +140,7 @@ class Contact {
|
|||||||
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List? get traceRouteBytes {
|
Uint8List get pathBytesForDisplay {
|
||||||
final pathBytes = _pathBytesForDisplay;
|
|
||||||
Uint8List? traceBytes;
|
|
||||||
|
|
||||||
if (pathBytes.isEmpty) {
|
|
||||||
traceBytes = Uint8List(1);
|
|
||||||
traceBytes[0] = publicKey[0];
|
|
||||||
return traceBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == advTypeRepeater || type == advTypeRoom) {
|
|
||||||
final len = (pathBytes.length + pathBytes.length + 1);
|
|
||||||
traceBytes = Uint8List(len);
|
|
||||||
traceBytes[pathBytes.length] = publicKey[0];
|
|
||||||
for (int i = 0; i < pathBytes.length; i++) {
|
|
||||||
traceBytes[i] = pathBytes[i];
|
|
||||||
if (i < pathBytes.length) {
|
|
||||||
traceBytes[len - 1 - i] = pathBytes[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (pathBytes.length < 2) {
|
|
||||||
return pathBytes[0] == 0 ? null : pathBytes;
|
|
||||||
}
|
|
||||||
final len = (pathBytes.length + pathBytes.length - 1);
|
|
||||||
traceBytes = Uint8List(len);
|
|
||||||
for (int i = 0; i < pathBytes.length; i++) {
|
|
||||||
traceBytes[i] = pathBytes[i];
|
|
||||||
if (i < pathBytes.length - 1) {
|
|
||||||
traceBytes[len - 1 - i] = pathBytes[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return traceBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Uint8List get _pathBytesForDisplay {
|
|
||||||
if (pathOverride != null) {
|
if (pathOverride != null) {
|
||||||
if (pathOverride! < 0) return Uint8List(0);
|
if (pathOverride! < 0) return Uint8List(0);
|
||||||
return pathOverrideBytes ?? Uint8List(0);
|
return pathOverrideBytes ?? Uint8List(0);
|
||||||
@@ -166,28 +150,28 @@ class Contact {
|
|||||||
|
|
||||||
static Contact? fromFrame(Uint8List data) {
|
static Contact? fromFrame(Uint8List data) {
|
||||||
if (data.isEmpty) return null;
|
if (data.isEmpty) return null;
|
||||||
if (data[0] != respCodeContact) return null;
|
final reader = BufferReader(data);
|
||||||
try {
|
try {
|
||||||
final pubKey = Uint8List.fromList(
|
final respCode = reader.readByte();
|
||||||
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
|
if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
|
||||||
);
|
return null;
|
||||||
final type = data[contactTypeOffset];
|
}
|
||||||
final flags = data[contactFlagsOffset];
|
final pubKey = reader.readBytes(pubKeySize);
|
||||||
final pathLen = data[contactPathLenOffset].toSigned(8);
|
final type = reader.readByte();
|
||||||
|
final flags = reader.readByte();
|
||||||
|
final pathLen = reader.readByte();
|
||||||
final safePathLen = pathLen > 0
|
final safePathLen = pathLen > 0
|
||||||
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
||||||
: 0;
|
: 0;
|
||||||
final pathBytes = safePathLen > 0
|
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||||
? Uint8List.fromList(
|
final name = reader.readCStringGreedy(maxNameSize);
|
||||||
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
|
|
||||||
)
|
final lastMod = reader.readUInt32LE();
|
||||||
: Uint8List(0);
|
|
||||||
final name = readCString(data, contactNameOffset, maxNameSize);
|
|
||||||
final lastmod = readUint32LE(data, contactLastModOffset);
|
|
||||||
|
|
||||||
double? lat, lon;
|
double? lat, lon;
|
||||||
final latRaw = readInt32LE(data, contactLatOffset);
|
final latRaw = reader.readInt32LE();
|
||||||
final lonRaw = readInt32LE(data, contactLonOffset);
|
final lonRaw = reader.readInt32LE();
|
||||||
|
|
||||||
if (latRaw != 0 || lonRaw != 0) {
|
if (latRaw != 0 || lonRaw != 0) {
|
||||||
lat = latRaw / 1e6;
|
lat = latRaw / 1e6;
|
||||||
lon = lonRaw / 1e6;
|
lon = lonRaw / 1e6;
|
||||||
@@ -198,14 +182,16 @@ class Contact {
|
|||||||
name: name.isEmpty ? 'Unknown' : name,
|
name: name.isEmpty ? 'Unknown' : name,
|
||||||
type: type,
|
type: type,
|
||||||
flags: flags,
|
flags: flags,
|
||||||
pathLength: pathLen,
|
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
|
||||||
path: pathBytes,
|
path: pathBytes,
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
|
||||||
|
isActive: true,
|
||||||
|
rawPacket: null,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If parsing fails, return null
|
appLogger.error('Failed to parse contact frame: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,4 +202,7 @@ class Contact {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => publicKeyHex.hashCode;
|
int get hashCode => publicKeyHex.hashCode;
|
||||||
|
bool get teleBaseEnabled => (flags & contactFlagTeleBase) != 0;
|
||||||
|
bool get teleLocEnabled => (flags & contactFlagTeleLoc) != 0;
|
||||||
|
bool get teleEnvEnabled => (flags & contactFlagTeleEnv) != 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class DeliveryObservation {
|
||||||
|
final String contactKey;
|
||||||
|
final int pathLength;
|
||||||
|
final int messageBytes;
|
||||||
|
final int secondsSinceLastRx;
|
||||||
|
final bool isFlood;
|
||||||
|
final int deliveryMs;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
DeliveryObservation({
|
||||||
|
required this.contactKey,
|
||||||
|
required this.pathLength,
|
||||||
|
required this.messageBytes,
|
||||||
|
required this.secondsSinceLastRx,
|
||||||
|
required this.isFlood,
|
||||||
|
required this.deliveryMs,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'contact_key': contactKey,
|
||||||
|
'path_length': pathLength,
|
||||||
|
'message_bytes': messageBytes,
|
||||||
|
'seconds_since_last_rx': secondsSinceLastRx,
|
||||||
|
'is_flood': isFlood,
|
||||||
|
'delivery_ms': deliveryMs,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DeliveryObservation(
|
||||||
|
contactKey: json['contact_key'] as String,
|
||||||
|
pathLength: json['path_length'] as int,
|
||||||
|
messageBytes: json['message_bytes'] as int,
|
||||||
|
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
|
||||||
|
isFlood: json['is_flood'] as bool,
|
||||||
|
deliveryMs: json['delivery_ms'] as int,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import '../connector/meshcore_protocol.dart';
|
|
||||||
|
|
||||||
class DiscoveryContact {
|
|
||||||
final Uint8List rawPacket;
|
|
||||||
final Uint8List publicKey;
|
|
||||||
final String name;
|
|
||||||
final int type;
|
|
||||||
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
|
|
||||||
final Uint8List path; // Path bytes from device
|
|
||||||
final double? latitude;
|
|
||||||
final double? longitude;
|
|
||||||
final DateTime lastSeen;
|
|
||||||
|
|
||||||
DiscoveryContact({
|
|
||||||
required this.rawPacket,
|
|
||||||
required this.publicKey,
|
|
||||||
required this.name,
|
|
||||||
required this.type,
|
|
||||||
required this.pathLength,
|
|
||||||
required this.path,
|
|
||||||
this.latitude,
|
|
||||||
this.longitude,
|
|
||||||
required this.lastSeen,
|
|
||||||
});
|
|
||||||
|
|
||||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
|
||||||
|
|
||||||
String get typeLabel {
|
|
||||||
switch (type) {
|
|
||||||
case advTypeChat:
|
|
||||||
return 'Chat';
|
|
||||||
case advTypeRepeater:
|
|
||||||
return 'Repeater';
|
|
||||||
case advTypeRoom:
|
|
||||||
return 'Room';
|
|
||||||
case advTypeSensor:
|
|
||||||
return 'Sensor';
|
|
||||||
default:
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String get pathLabel {
|
|
||||||
if (pathLength < 0) return 'Flood';
|
|
||||||
if (pathLength == 0) return 'Direct';
|
|
||||||
return '$pathLength hops';
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get hasLocation => latitude != null && longitude != null;
|
|
||||||
|
|
||||||
DiscoveryContact copyWith({
|
|
||||||
Uint8List? rawPacket,
|
|
||||||
Uint8List? publicKey,
|
|
||||||
String? name,
|
|
||||||
int? type,
|
|
||||||
int? pathLength,
|
|
||||||
Uint8List? path,
|
|
||||||
double? latitude,
|
|
||||||
double? longitude,
|
|
||||||
DateTime? lastSeen,
|
|
||||||
}) {
|
|
||||||
return DiscoveryContact(
|
|
||||||
rawPacket: rawPacket ?? this.rawPacket,
|
|
||||||
publicKey: publicKey ?? this.publicKey,
|
|
||||||
name: name ?? this.name,
|
|
||||||
type: type ?? this.type,
|
|
||||||
pathLength: pathLength ?? this.pathLength,
|
|
||||||
path: path ?? this.path,
|
|
||||||
latitude: latitude ?? this.latitude,
|
|
||||||
longitude: longitude ?? this.longitude,
|
|
||||||
lastSeen: lastSeen ?? this.lastSeen,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String get pathIdList {
|
|
||||||
final pathBytes = path;
|
|
||||||
if (pathBytes.isEmpty) return '';
|
|
||||||
final parts = <String>[];
|
|
||||||
final groupSize = pathHashSize;
|
|
||||||
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
|
||||||
final end = (i + groupSize) <= pathBytes.length
|
|
||||||
? (i + groupSize)
|
|
||||||
: pathBytes.length;
|
|
||||||
final chunk = pathBytes.sublist(i, end);
|
|
||||||
parts.add(
|
|
||||||
chunk
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
||||||
.join(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parts.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
String get shortPubKeyHex {
|
|
||||||
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
other is DiscoveryContact && publicKeyHex == other.publicKeyHex;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => publicKeyHex.hashCode;
|
|
||||||
}
|
|
||||||
@@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
: Icons.download,
|
: Icons.download,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
|
onLongPress: () async {
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(
|
||||||
|
text: entry.payload
|
||||||
|
.map(
|
||||||
|
(b) => b
|
||||||
|
.toRadixString(16)
|
||||||
|
.padLeft(2, '0'),
|
||||||
|
)
|
||||||
|
.join(''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,33 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'clearChat') {
|
||||||
|
context.read<MeshCoreConnector>().clearMessagesForChannel(
|
||||||
|
widget.channel.index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'clearChat',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.delete, size: 20, color: Colors.red),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
context.l10n.contact_clearChat,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
final primaryPath = !channelMessage && !message.isOutgoing
|
final primaryPath = !channelMessage && !message.isOutgoing
|
||||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||||
: primaryPathTmp;
|
: primaryPathTmp;
|
||||||
|
final contacts = connector.allContacts;
|
||||||
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
|
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||||
final hasHopDetails = primaryPath.isNotEmpty;
|
final hasHopDetails = primaryPath.isNotEmpty;
|
||||||
final observedLabel = _formatObservedHops(
|
final observedLabel = _formatObservedHops(
|
||||||
primaryPath.length,
|
primaryPath.length,
|
||||||
@@ -62,8 +62,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
builder: (context) => PathTraceMapScreen(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: context.l10n.contacts_repeaterPathTrace,
|
title: context.l10n.contacts_repeaterPathTrace,
|
||||||
path: primaryPath,
|
path: primaryPath,
|
||||||
flipPathRound: true,
|
flipPathAround: true,
|
||||||
reversePathRound: !message.isOutgoing && !channelMessage,
|
reversePathAround:
|
||||||
|
!(!channelMessage && !message.isOutgoing),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -364,11 +365,8 @@ class _ChannelMessagePathMapScreenState
|
|||||||
: selectedPathTmp;
|
: selectedPathTmp;
|
||||||
|
|
||||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||||
final hops = _buildPathHops(
|
final contacts = connector.allContacts;
|
||||||
selectedPath,
|
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||||
connector.contacts,
|
|
||||||
context.l10n,
|
|
||||||
);
|
|
||||||
|
|
||||||
final points = <LatLng>[];
|
final points = <LatLng>[];
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
|
import '../services/ui_view_state_service.dart';
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
import '../models/community.dart';
|
import '../models/community.dart';
|
||||||
import '../storage/community_store.dart';
|
import '../storage/community_store.dart';
|
||||||
@@ -28,8 +29,6 @@ import 'contacts_screen.dart';
|
|||||||
import 'map_screen.dart';
|
import 'map_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
enum ChannelSortOption { manual, name, latestMessages, unread }
|
|
||||||
|
|
||||||
class ChannelsScreen extends StatefulWidget {
|
class ChannelsScreen extends StatefulWidget {
|
||||||
final bool hideBackButton;
|
final bool hideBackButton;
|
||||||
|
|
||||||
@@ -43,17 +42,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
with DisconnectNavigationMixin {
|
with DisconnectNavigationMixin {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final CommunityStore _communityStore = CommunityStore();
|
final CommunityStore _communityStore = CommunityStore();
|
||||||
String _searchQuery = '';
|
|
||||||
Timer? _searchDebounce;
|
Timer? _searchDebounce;
|
||||||
ChannelSortOption _sortOption = ChannelSortOption.manual;
|
|
||||||
List<Community> _communities = [];
|
List<Community> _communities = [];
|
||||||
|
|
||||||
// Cache of PSK hex -> Community for quick lookup
|
// Cache of PSK hex -> Community for quick lookup
|
||||||
final Map<String, Community> _pskToCommunity = {};
|
final Map<String, Community> _pskToCommunity = {};
|
||||||
|
|
||||||
|
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_searchController.text = context
|
||||||
|
.read<UiViewStateService>()
|
||||||
|
.channelsSearchText;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<MeshCoreConnector>().getChannels();
|
context.read<MeshCoreConnector>().getChannels();
|
||||||
_loadCommunities();
|
_loadCommunities();
|
||||||
@@ -61,6 +63,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCommunities() async {
|
Future<void> _loadCommunities() async {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
final communities = await _communityStore.loadCommunities();
|
final communities = await _communityStore.loadCommunities();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -106,7 +110,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final viewState = context.watch<UiViewStateService>();
|
||||||
|
|
||||||
final channelMessageStore = ChannelMessageStore();
|
final channelMessageStore = ChannelMessageStore();
|
||||||
|
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
|
||||||
// Auto-navigate back to scanner if disconnected
|
// Auto-navigate back to scanner if disconnected
|
||||||
if (!checkConnectionAndNavigate(connector)) {
|
if (!checkConnectionAndNavigate(connector)) {
|
||||||
@@ -199,6 +206,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
final filteredChannels = _filterAndSortChannels(
|
final filteredChannels = _filterAndSortChannels(
|
||||||
channels,
|
channels,
|
||||||
connector,
|
connector,
|
||||||
|
viewState,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -213,17 +221,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
suffixIcon: Row(
|
suffixIcon: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (_searchQuery.isNotEmpty)
|
if (viewState.channelsSearchText.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
_searchDebounce = null;
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
setState(() {
|
context
|
||||||
_searchQuery = '';
|
.read<UiViewStateService>()
|
||||||
});
|
.setChannelsSearchText('');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildFilterButton(),
|
_buildFilterButton(viewState),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@@ -240,9 +250,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
const Duration(milliseconds: 300),
|
const Duration(milliseconds: 300),
|
||||||
() {
|
() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
context
|
||||||
_searchQuery = value.toLowerCase();
|
.read<UiViewStateService>()
|
||||||
});
|
.setChannelsSearchText(value);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -277,8 +287,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: (_sortOption == ChannelSortOption.manual &&
|
: (viewState.channelsSortOption ==
|
||||||
_searchQuery.isEmpty)
|
ChannelSortOption.manual &&
|
||||||
|
viewState.channelsSearchText.isEmpty)
|
||||||
? ReorderableListView.builder(
|
? ReorderableListView.builder(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 16,
|
left: 16,
|
||||||
@@ -578,59 +589,40 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
await showDisconnectDialog(context, connector);
|
await showDisconnectDialog(context, connector);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterButton() {
|
Widget _buildFilterButton(UiViewStateService viewState) {
|
||||||
const actionSortManual = 0;
|
return SortFilterMenu<ChannelSortOption>(
|
||||||
const actionSortName = 1;
|
|
||||||
const actionSortLatest = 2;
|
|
||||||
const actionSortUnread = 3;
|
|
||||||
|
|
||||||
return SortFilterMenu(
|
|
||||||
tooltip: context.l10n.listFilter_tooltip,
|
tooltip: context.l10n.listFilter_tooltip,
|
||||||
sections: [
|
sections: [
|
||||||
SortFilterMenuSection(
|
SortFilterMenuSection<ChannelSortOption>(
|
||||||
title: context.l10n.channels_sortBy,
|
title: context.l10n.channels_sortBy,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption<ChannelSortOption>(
|
||||||
value: actionSortManual,
|
value: ChannelSortOption.manual,
|
||||||
label: context.l10n.channels_sortManual,
|
label: context.l10n.channels_sortManual,
|
||||||
checked: _sortOption == ChannelSortOption.manual,
|
checked: viewState.channelsSortOption == ChannelSortOption.manual,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption<ChannelSortOption>(
|
||||||
value: actionSortName,
|
value: ChannelSortOption.name,
|
||||||
label: context.l10n.channels_sortAZ,
|
label: context.l10n.channels_sortAZ,
|
||||||
checked: _sortOption == ChannelSortOption.name,
|
checked: viewState.channelsSortOption == ChannelSortOption.name,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption<ChannelSortOption>(
|
||||||
value: actionSortLatest,
|
value: ChannelSortOption.latestMessages,
|
||||||
label: context.l10n.channels_sortLatestMessages,
|
label: context.l10n.channels_sortLatestMessages,
|
||||||
checked: _sortOption == ChannelSortOption.latestMessages,
|
checked:
|
||||||
|
viewState.channelsSortOption ==
|
||||||
|
ChannelSortOption.latestMessages,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption<ChannelSortOption>(
|
||||||
value: actionSortUnread,
|
value: ChannelSortOption.unread,
|
||||||
label: context.l10n.channels_sortUnread,
|
label: context.l10n.channels_sortUnread,
|
||||||
checked: _sortOption == ChannelSortOption.unread,
|
checked: viewState.channelsSortOption == ChannelSortOption.unread,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (action) {
|
onSelected: (sortOption) {
|
||||||
setState(() {
|
viewState.setChannelsSortOption(sortOption);
|
||||||
switch (action) {
|
|
||||||
case actionSortManual:
|
|
||||||
_sortOption = ChannelSortOption.manual;
|
|
||||||
break;
|
|
||||||
case actionSortLatest:
|
|
||||||
_sortOption = ChannelSortOption.latestMessages;
|
|
||||||
break;
|
|
||||||
case actionSortUnread:
|
|
||||||
_sortOption = ChannelSortOption.unread;
|
|
||||||
break;
|
|
||||||
case actionSortName:
|
|
||||||
default:
|
|
||||||
_sortOption = ChannelSortOption.name;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -638,11 +630,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
List<Channel> _filterAndSortChannels(
|
List<Channel> _filterAndSortChannels(
|
||||||
List<Channel> channels,
|
List<Channel> channels,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
|
UiViewStateService viewState,
|
||||||
) {
|
) {
|
||||||
var filtered = channels.where((channel) {
|
var filtered = channels.where((channel) {
|
||||||
if (_searchQuery.isEmpty) return true;
|
if (viewState.channelsSearchText.isEmpty) return true;
|
||||||
final label = _normalizeChannelName(channel);
|
final label = _normalizeChannelName(channel);
|
||||||
return label.toLowerCase().contains(_searchQuery);
|
return label.toLowerCase().contains(
|
||||||
|
viewState.channelsSearchText.toLowerCase(),
|
||||||
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
int compareByName(Channel a, Channel b) {
|
int compareByName(Channel a, Channel b) {
|
||||||
@@ -651,7 +646,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (_sortOption) {
|
switch (viewState.channelsSortOption) {
|
||||||
case ChannelSortOption.manual:
|
case ChannelSortOption.manual:
|
||||||
break;
|
break;
|
||||||
case ChannelSortOption.latestMessages:
|
case ChannelSortOption.latestMessages:
|
||||||
@@ -712,6 +707,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
bool isRegularHashtag = true;
|
bool isRegularHashtag = true;
|
||||||
Community? selectedCommunity;
|
Community? selectedCommunity;
|
||||||
|
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => StatefulBuilder(
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
@@ -763,7 +760,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget? buildExpandedContent() {
|
Widget? buildExpandedContent(
|
||||||
|
ChannelMessageStore channelMessageStore,
|
||||||
|
) {
|
||||||
switch (selectedOption) {
|
switch (selectedOption) {
|
||||||
case 0: // Create Private Channel
|
case 0: // Create Private Channel
|
||||||
return Column(
|
return Column(
|
||||||
@@ -788,7 +787,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
final name = nameController.text.trim();
|
final name = nameController.text.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
@@ -810,7 +809,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
psk[i] = random.nextInt(256);
|
psk[i] = random.nextInt(256);
|
||||||
}
|
}
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
connector.setChannel(nextIndex, name, psk);
|
await connector.setChannel(
|
||||||
|
nextIndex,
|
||||||
|
name,
|
||||||
|
psk,
|
||||||
|
);
|
||||||
|
await channelMessageStore.clearChannelMessages(
|
||||||
|
nextIndex,
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -1329,7 +1335,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
subtitle:
|
subtitle:
|
||||||
dialogContext.l10n.channels_createPrivateChannelDesc,
|
dialogContext.l10n.channels_createPrivateChannelDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 0) buildExpandedContent()!,
|
if (selectedOption == 0)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
optionIndex: 1,
|
optionIndex: 1,
|
||||||
@@ -1338,7 +1345,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
subtitle:
|
subtitle:
|
||||||
dialogContext.l10n.channels_joinPrivateChannelDesc,
|
dialogContext.l10n.channels_joinPrivateChannelDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 1) buildExpandedContent()!,
|
if (selectedOption == 1)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
if (!hasPublicChannel) ...[
|
if (!hasPublicChannel) ...[
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
@@ -1348,7 +1356,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
subtitle:
|
subtitle:
|
||||||
dialogContext.l10n.channels_joinPublicChannelDesc,
|
dialogContext.l10n.channels_joinPublicChannelDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 2) buildExpandedContent()!,
|
if (selectedOption == 2)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
],
|
],
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
@@ -1358,7 +1367,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
subtitle:
|
subtitle:
|
||||||
dialogContext.l10n.channels_joinHashtagChannelDesc,
|
dialogContext.l10n.channels_joinHashtagChannelDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 3) buildExpandedContent()!,
|
if (selectedOption == 3)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
optionIndex: 4,
|
optionIndex: 4,
|
||||||
@@ -1366,7 +1376,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
title: dialogContext.l10n.community_scanQr,
|
title: dialogContext.l10n.community_scanQr,
|
||||||
subtitle: dialogContext.l10n.community_join,
|
subtitle: dialogContext.l10n.community_join,
|
||||||
),
|
),
|
||||||
if (selectedOption == 4) buildExpandedContent()!,
|
if (selectedOption == 4)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
optionIndex: 5,
|
optionIndex: 5,
|
||||||
@@ -1374,7 +1385,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
title: dialogContext.l10n.community_create,
|
title: dialogContext.l10n.community_create,
|
||||||
subtitle: dialogContext.l10n.community_createDesc,
|
subtitle: dialogContext.l10n.community_createDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 5) buildExpandedContent()!,
|
if (selectedOption == 5)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1524,7 +1536,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
try {
|
try {
|
||||||
await connector.deleteChannel(channel.index);
|
await connector.deleteChannel(channel.index);
|
||||||
|
|
||||||
channelMessageStore.clearChannelMessages(channel.index);
|
await channelMessageStore.clearChannelMessages(channel.index);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
@@ -1749,6 +1761,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final channelCount = communityChannels.length;
|
final channelCount = communityChannels.length;
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
+268
-56
@@ -36,6 +36,7 @@ import '../widgets/gif_picker.dart';
|
|||||||
import '../widgets/path_selection_dialog.dart';
|
import '../widgets/path_selection_dialog.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import 'telemetry_screen.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
@@ -106,10 +107,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
||||||
final pathLabel = _currentPathLabel(contact);
|
final pathLabel = _currentPathLabel(contact);
|
||||||
|
|
||||||
// Show path details if we have path data (from device or override)
|
// Show path details if we have non-empty path data (from device or override)
|
||||||
final hasPathData =
|
|
||||||
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
|
||||||
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
||||||
|
final hasPathData = effectivePath.isNotEmpty;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -143,12 +143,25 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final contact = _resolveContact(connector);
|
final contact = _resolveContact(connector);
|
||||||
final isFloodMode = contact.pathOverride == -1;
|
final isFloodMode = contact.pathOverride == -1;
|
||||||
|
|
||||||
|
final isDirectMode = contact.pathOverride == 0;
|
||||||
|
final activeMode = isFloodMode
|
||||||
|
? 'flood'
|
||||||
|
: isDirectMode
|
||||||
|
? 'direct'
|
||||||
|
: 'auto';
|
||||||
|
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
tooltip: context.l10n.chat_routingMode,
|
tooltip: context.l10n.chat_routingMode,
|
||||||
onSelected: (mode) async {
|
onSelected: (mode) async {
|
||||||
if (mode == 'flood') {
|
if (mode == 'flood') {
|
||||||
await connector.setPathOverride(contact, pathLen: -1);
|
await connector.setPathOverride(contact, pathLen: -1);
|
||||||
|
} else if (mode == 'direct') {
|
||||||
|
await connector.setPathOverride(
|
||||||
|
contact,
|
||||||
|
pathLen: 0,
|
||||||
|
pathBytes: Uint8List(0),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await connector.setPathOverride(contact, pathLen: null);
|
await connector.setPathOverride(contact, pathLen: null);
|
||||||
}
|
}
|
||||||
@@ -161,7 +174,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.auto_mode,
|
Icons.auto_mode,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: !isFloodMode
|
color: activeMode == 'auto'
|
||||||
? Theme.of(context).primaryColor
|
? Theme.of(context).primaryColor
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -169,7 +182,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Text(
|
Text(
|
||||||
context.l10n.chat_autoUseSavedPath,
|
context.l10n.chat_autoUseSavedPath,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: !isFloodMode
|
fontWeight: activeMode == 'auto'
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'direct',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.near_me,
|
||||||
|
size: 20,
|
||||||
|
color: activeMode == 'direct'
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
context.l10n.chat_direct,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: activeMode == 'direct'
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -184,7 +220,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.waves,
|
Icons.waves,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: isFloodMode
|
color: activeMode == 'flood'
|
||||||
? Theme.of(context).primaryColor
|
? Theme.of(context).primaryColor
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -192,7 +228,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Text(
|
Text(
|
||||||
context.l10n.chat_forceFloodMode,
|
context.l10n.chat_forceFloodMode,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: isFloodMode
|
fontWeight: activeMode == 'flood'
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -209,9 +245,77 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
tooltip: context.l10n.chat_pathManagement,
|
tooltip: context.l10n.chat_pathManagement,
|
||||||
onPressed: () => _showPathHistory(context),
|
onPressed: () => _showPathHistory(context),
|
||||||
),
|
),
|
||||||
IconButton(
|
Consumer<MeshCoreConnector>(
|
||||||
icon: const Icon(Icons.info_outline),
|
builder: (context, connector, _) {
|
||||||
onPressed: () => _showContactInfo(context),
|
return PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'info') {
|
||||||
|
_showContactInfo(context);
|
||||||
|
}
|
||||||
|
if (value == 'settings') {
|
||||||
|
_showContactSettings(context);
|
||||||
|
}
|
||||||
|
if (value == 'telemetry') {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
TelemetryScreen(contact: widget.contact),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value == 'clearChat') {
|
||||||
|
connector.clearMessagesForContact(widget.contact);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'info',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.info_outline, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(context.l10n.contact_info),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'telemetry',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.bar_chart, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(context.l10n.contact_telemetry),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'settings',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.settings, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(context.l10n.contact_settings),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'clearChat',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.delete, size: 20, color: Colors.red),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
context.l10n.contact_clearChat,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -251,7 +355,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.chat_sendMessageTo(widget.contact.name),
|
context.l10n.chat_sendMessageTo(
|
||||||
|
_resolveContact(context.read<MeshCoreConnector>()).name,
|
||||||
|
),
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -269,6 +375,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// Auto-scroll to bottom if user is already at bottom
|
// Auto-scroll to bottom if user is already at bottom
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
_scrollController.scrollToBottomIfAtBottom();
|
_scrollController.scrollToBottomIfAtBottom();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,10 +400,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final messageIndex = index;
|
final messageIndex = index;
|
||||||
Contact contact = widget.contact;
|
Contact contact = _resolveContact(connector);
|
||||||
final message = reversedMessages[messageIndex];
|
final message = reversedMessages[messageIndex];
|
||||||
String fourByteHex = '';
|
String fourByteHex = '';
|
||||||
if (widget.contact.type == advTypeRoom) {
|
if (contact.type == advTypeRoom) {
|
||||||
contact = _resolveContactFrom4Bytes(
|
contact = _resolveContactFrom4Bytes(
|
||||||
connector,
|
connector,
|
||||||
message.fourByteRoomContactKey.isEmpty
|
message.fourByteRoomContactKey.isEmpty
|
||||||
@@ -314,12 +421,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final textScale = context.select<ChatTextScaleService, double>(
|
final textScale = context.select<ChatTextScaleService, double>(
|
||||||
(service) => service.scale,
|
(service) => service.scale,
|
||||||
);
|
);
|
||||||
|
final resolvedContact = _resolveContact(connector);
|
||||||
return _MessageBubble(
|
return _MessageBubble(
|
||||||
message: message,
|
message: message,
|
||||||
senderName: widget.contact.type == advTypeRoom
|
senderName: resolvedContact.type == advTypeRoom
|
||||||
? "${contact.name} [$fourByteHex]"
|
? "${contact.name} [$fourByteHex]"
|
||||||
: contact.name,
|
: contact.name,
|
||||||
isRoomServer: widget.contact.type == advTypeRoom,
|
isRoomServer: resolvedContact.type == advTypeRoom,
|
||||||
textScale: textScale,
|
textScale: textScale,
|
||||||
onTap: () => _openMessagePath(message, contact),
|
onTap: () => _openMessagePath(message, contact),
|
||||||
onLongPress: () => _showMessageActions(message, contact),
|
onLongPress: () => _showMessageActions(message, contact),
|
||||||
@@ -457,7 +565,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connector.sendMessage(widget.contact, text);
|
connector.sendMessage(_resolveContact(connector), text);
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_textFieldFocusNode.requestFocus();
|
_textFieldFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
@@ -654,7 +762,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// Set the path override to persist user's choice
|
// Set the path override to persist user's choice
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: pathLength,
|
pathLen: pathLength,
|
||||||
pathBytes: pathBytes,
|
pathBytes: pathBytes,
|
||||||
);
|
);
|
||||||
@@ -663,7 +771,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
await _notifyPathSet(
|
await _notifyPathSet(
|
||||||
connector,
|
connector,
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathBytes,
|
pathBytes,
|
||||||
path.hopCount,
|
path.hopCount,
|
||||||
);
|
);
|
||||||
@@ -722,7 +830,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
style: const TextStyle(fontSize: 11),
|
style: const TextStyle(fontSize: 11),
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await connector.clearContactPath(widget.contact);
|
await connector.clearContactPath(
|
||||||
|
_resolveContact(connector),
|
||||||
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -750,7 +860,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: -1,
|
pathLen: -1,
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -817,7 +927,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
builder: (context) => PathTraceMapScreen(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: context.l10n.contacts_repeaterPathTrace,
|
title: context.l10n.contacts_repeaterPathTrace,
|
||||||
path: Uint8List.fromList(pathBytes),
|
path: Uint8List.fromList(pathBytes),
|
||||||
flipPathRound: true,
|
flipPathAround: true,
|
||||||
targetContact: widget.contact,
|
targetContact: widget.contact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -833,11 +943,22 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _resolveContactIndex = -1;
|
||||||
|
|
||||||
Contact _resolveContact(MeshCoreConnector connector) {
|
Contact _resolveContact(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveContactIndex >= 0 &&
|
||||||
|
_resolveContactIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveContactIndex].publicKeyHex ==
|
||||||
|
widget.contact.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveContactIndex];
|
||||||
|
}
|
||||||
|
_resolveContactIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
||||||
orElse: () => widget.contact,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveContactIndex == -1) {
|
||||||
|
return widget.contact;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveContactIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact _resolveContactFrom4Bytes(
|
Contact _resolveContactFrom4Bytes(
|
||||||
@@ -890,19 +1011,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
void _showContactInfo(BuildContext context) {
|
void _showContactInfo(BuildContext context) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
final contact = _resolveContact(connector);
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Consumer<MeshCoreConnector>(
|
builder: (context) => AlertDialog(
|
||||||
builder: (context, connector, _) {
|
title: SelectableText(contact.name),
|
||||||
final contact = _resolveContact(connector);
|
|
||||||
final smazEnabled = connector.isContactSmazEnabled(
|
|
||||||
contact.publicKeyHex,
|
|
||||||
);
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text(contact.name),
|
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -910,16 +1023,54 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
|
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
|
||||||
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
|
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
|
||||||
|
_buildInfoRow(
|
||||||
|
context.l10n.contact_lastSeen,
|
||||||
|
_formatContactLastMessage(contact.lastMessageAt),
|
||||||
|
),
|
||||||
if (contact.hasLocation)
|
if (contact.hasLocation)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context.l10n.chat_location,
|
context.l10n.chat_location,
|
||||||
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||||
),
|
),
|
||||||
_buildInfoRow(
|
_buildInfoRow(context.l10n.chat_publicKey, contact.publicKeyHex),
|
||||||
context.l10n.chat_publicKey,
|
],
|
||||||
'${contact.publicKeyHex.substring(0, 16)}...',
|
|
||||||
),
|
),
|
||||||
const Divider(),
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(context.l10n.common_close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showContactSettings(BuildContext context) {
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
||||||
|
final contact = widget.contact;
|
||||||
|
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
|
||||||
|
bool teleBaseEnabled = contact.teleBaseEnabled;
|
||||||
|
bool teleLocEnabled = contact.teleLocEnabled;
|
||||||
|
bool teleEnvEnabled = contact.teleEnvEnabled;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: Text(context.l10n.contact_settings),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (contact.hasLocation) ...[
|
||||||
|
_buildInfoRow(
|
||||||
|
context.l10n.chat_location,
|
||||||
|
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
],
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(context.l10n.channels_smazCompression),
|
title: Text(context.l10n.channels_smazCompression),
|
||||||
@@ -930,6 +1081,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
contact.publicKeyHex,
|
contact.publicKeyHex,
|
||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
|
setDialogState(() => smazEnabled = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(context.l10n.contact_teleBase),
|
||||||
|
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
|
||||||
|
value: teleBaseEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => teleBaseEnabled = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(context.l10n.contact_teleLoc),
|
||||||
|
subtitle: Text(context.l10n.contact_teleLocSubtitle),
|
||||||
|
value: teleLocEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => teleLocEnabled = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(context.l10n.contact_teleEnv),
|
||||||
|
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
|
||||||
|
value: teleEnvEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => teleEnvEnabled = value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -937,12 +1119,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () {
|
||||||
|
connector.setContactFlags(
|
||||||
|
contact,
|
||||||
|
teleBase: teleBaseEnabled,
|
||||||
|
teleLoc: teleLocEnabled,
|
||||||
|
teleEnv: teleEnvEnabled,
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
child: Text(context.l10n.common_close),
|
child: Text(context.l10n.common_close),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -957,12 +1146,32 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
width: 80,
|
width: 80,
|
||||||
child: Text(label, style: TextStyle(color: Colors.grey[600])),
|
child: Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
),
|
),
|
||||||
Expanded(child: Text(value)),
|
Expanded(child: SelectableText(value)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatContactLastMessage(DateTime timestamp) {
|
||||||
|
final diff = DateTime.now().difference(timestamp);
|
||||||
|
if (diff.isNegative || diff.inMinutes < 5) {
|
||||||
|
return context.l10n.contacts_lastSeenNow;
|
||||||
|
}
|
||||||
|
if (diff.inMinutes < 60) {
|
||||||
|
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
|
||||||
|
}
|
||||||
|
if (diff.inHours < 24) {
|
||||||
|
final hours = diff.inHours;
|
||||||
|
return hours == 1
|
||||||
|
? context.l10n.contacts_lastSeenHourAgo
|
||||||
|
: context.l10n.contacts_lastSeenHoursAgo(hours);
|
||||||
|
}
|
||||||
|
final days = diff.inDays;
|
||||||
|
return days == 1
|
||||||
|
? context.l10n.contacts_lastSeenDayAgo
|
||||||
|
: context.l10n.contacts_lastSeenDaysAgo(days);
|
||||||
|
}
|
||||||
|
|
||||||
void _openChat(BuildContext context, Contact contact) {
|
void _openChat(BuildContext context, Contact contact) {
|
||||||
// Check if this is a repeater
|
// Check if this is a repeater
|
||||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
||||||
@@ -986,7 +1195,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final currentPathLabel = _currentPathLabel(currentContact);
|
final currentPathLabel = _currentPathLabel(currentContact);
|
||||||
|
|
||||||
// Filter out the current contact from available contacts
|
// Filter out the current contact from available contacts
|
||||||
final availableContacts = connector.contacts
|
final availableContacts = connector.allContacts
|
||||||
.where((c) => c != widget.contact)
|
.where((c) => c != widget.contact)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -1005,11 +1214,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
appLogger.info(
|
return; // Cancelled — keep existing path
|
||||||
'PathSelectionDialog was cancelled or returned null',
|
|
||||||
tag: 'ChatScreen',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -1025,14 +1230,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
tag: 'ChatScreen',
|
tag: 'ChatScreen',
|
||||||
);
|
);
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: result.length,
|
pathLen: result.length,
|
||||||
pathBytes: result,
|
pathBytes: result,
|
||||||
);
|
);
|
||||||
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _notifyPathSet(connector, widget.contact, result, result.length);
|
await _notifyPathSet(
|
||||||
|
connector,
|
||||||
|
_resolveContact(connector),
|
||||||
|
result,
|
||||||
|
result.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openMessagePath(Message message, Contact contact) {
|
void _openMessagePath(Message message, Contact contact) {
|
||||||
@@ -1044,10 +1254,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final String senderName;
|
final String senderName;
|
||||||
if (message.isOutgoing) {
|
if (message.isOutgoing) {
|
||||||
senderName = connector.selfName ?? context.l10n.chat_me;
|
senderName = connector.selfName ?? context.l10n.chat_me;
|
||||||
} else if (widget.contact.type == advTypeRoom) {
|
} else if (_resolveContact(connector).type == advTypeRoom) {
|
||||||
senderName = "${contact.name} [$fourByteHex]";
|
senderName = "${contact.name} [$fourByteHex]";
|
||||||
} else {
|
} else {
|
||||||
senderName = widget.contact.name;
|
senderName = _resolveContact(connector).name;
|
||||||
}
|
}
|
||||||
final pathMessage = ChannelMessage(
|
final pathMessage = ChannelMessage(
|
||||||
senderKey: null,
|
senderKey: null,
|
||||||
@@ -1110,7 +1320,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_retryMessage(message);
|
_retryMessage(message);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (widget.contact.type == advTypeRoom)
|
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
|
||||||
|
advTypeRoom)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.chat),
|
leading: const Icon(Icons.chat),
|
||||||
title: Text(context.l10n.contacts_openChat),
|
title: Text(context.l10n.contacts_openChat),
|
||||||
@@ -1148,7 +1359,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
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(widget.contact, message.text);
|
connector.sendMessage(_resolveContact(connector), message.text);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
||||||
@@ -1174,7 +1385,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// For room servers, include sender name (like channels) since multiple users
|
// For room servers, include sender name (like channels) since multiple users
|
||||||
// For 1:1 chats, sender is implicit (null)
|
// For 1:1 chats, sender is implicit (null)
|
||||||
final senderName = widget.contact.type == advTypeRoom
|
final liveContact = _resolveContact(connector);
|
||||||
|
final senderName = liveContact.type == advTypeRoom
|
||||||
? senderContact.name
|
? senderContact.name
|
||||||
: null;
|
: null;
|
||||||
final hash = ReactionHelper.computeReactionHash(
|
final hash = ReactionHelper.computeReactionHash(
|
||||||
@@ -1183,7 +1395,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
message.text,
|
message.text,
|
||||||
);
|
);
|
||||||
final reactionText = 'r:$hash:$emojiIndex';
|
final reactionText = 'r:$hash:$emojiIndex';
|
||||||
connector.sendMessage(widget.contact, reactionText);
|
connector.sendMessage(_resolveContact(connector), reactionText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse the community data
|
// Parse the community data
|
||||||
final community = Community.fromQrData(const Uuid().v4(), data);
|
final community = Community.fromQrData(const Uuid().v4(), data);
|
||||||
@@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
bool addPublicChannel,
|
bool addPublicChannel,
|
||||||
) async {
|
) async {
|
||||||
// Save community to local storage
|
// Save community to local storage
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
await _communityStore.addCommunity(community);
|
await _communityStore.addCommunity(community);
|
||||||
|
|
||||||
// Optionally add the community public channel to the device
|
// Optionally add the community public channel to the device
|
||||||
|
|||||||
+416
-225
@@ -3,6 +3,7 @@ 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:meshcore_open/screens/path_trace_map.dart';
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||||
|
import 'package:meshcore_open/services/notification_service.dart';
|
||||||
import 'package:meshcore_open/utils/app_logger.dart';
|
import 'package:meshcore_open/utils/app_logger.dart';
|
||||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -12,8 +13,9 @@ import '../l10n/l10n.dart';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/contact_group.dart';
|
import '../models/contact_group.dart';
|
||||||
import '../storage/contact_group_store.dart';
|
import '../services/ui_view_state_service.dart';
|
||||||
import '../utils/contact_search.dart';
|
import '../utils/contact_search.dart';
|
||||||
|
import '../storage/contact_group_store.dart';
|
||||||
import '../utils/dialog_utils.dart';
|
import '../utils/dialog_utils.dart';
|
||||||
import '../utils/disconnect_navigation_mixin.dart';
|
import '../utils/disconnect_navigation_mixin.dart';
|
||||||
import '../utils/emoji_utils.dart';
|
import '../utils/emoji_utils.dart';
|
||||||
@@ -47,12 +49,10 @@ class ContactsScreen extends StatefulWidget {
|
|||||||
class _ContactsScreenState extends State<ContactsScreen>
|
class _ContactsScreenState extends State<ContactsScreen>
|
||||||
with DisconnectNavigationMixin {
|
with DisconnectNavigationMixin {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
String _searchQuery = '';
|
|
||||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
|
||||||
bool _showUnreadOnly = false;
|
|
||||||
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
|
|
||||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||||
|
MeshCoreConnector? _scopeSyncConnector;
|
||||||
List<ContactGroup> _groups = [];
|
List<ContactGroup> _groups = [];
|
||||||
|
String _loadedGroupScopeKeyHex = '';
|
||||||
Timer? _searchDebounce;
|
Timer? _searchDebounce;
|
||||||
|
|
||||||
final Set<ContactOperationType> _pendingOperations = {};
|
final Set<ContactOperationType> _pendingOperations = {};
|
||||||
@@ -62,8 +62,30 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_searchController.text = context
|
||||||
|
.read<UiViewStateService>()
|
||||||
|
.contactsSearchText;
|
||||||
_loadGroups();
|
_loadGroups();
|
||||||
_setupFrameListener();
|
_setupFrameListener();
|
||||||
|
_clearAdvertNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearAdvertNotifications() {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
|
||||||
|
NotificationService().clearAdvertNotifications(contactIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
if (!identical(_scopeSyncConnector, connector)) {
|
||||||
|
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
|
||||||
|
_scopeSyncConnector = connector;
|
||||||
|
_scopeSyncConnector?.addListener(_handleConnectorScopeChange);
|
||||||
|
}
|
||||||
|
_handleConnectorScopeChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -71,21 +93,67 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
_searchDebounce?.cancel();
|
_searchDebounce?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_frameSubscription?.cancel();
|
_frameSubscription?.cancel();
|
||||||
|
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleConnectorScopeChange() {
|
||||||
|
final connector = _scopeSyncConnector;
|
||||||
|
if (connector == null) return;
|
||||||
|
_syncGroupScopeIfNeeded(connector);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadGroups() async {
|
Future<void> _loadGroups() async {
|
||||||
|
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
|
||||||
|
if (selfPublicKeyHex.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_groupStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
final groups = await _groupStore.loadGroups();
|
final groups = await _groupStore.loadGroups();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_loadedGroupScopeKeyHex = selfPublicKeyHex;
|
||||||
_groups = groups;
|
_groups = groups;
|
||||||
|
_ensureValidSelectedGroup();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveGroups() async {
|
Future<void> _saveGroups() async {
|
||||||
|
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
|
||||||
|
if (selfPublicKeyHex.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_groupStore.setPublicKeyHex = selfPublicKeyHex;
|
||||||
await _groupStore.saveGroups(_groups);
|
await _groupStore.saveGroups(_groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _hasGroupStoreScope(MeshCoreConnector connector) {
|
||||||
|
return connector.selfPublicKeyHex.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncGroupScopeIfNeeded(MeshCoreConnector connector) {
|
||||||
|
final selfPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
if (selfPublicKeyHex.isEmpty ||
|
||||||
|
selfPublicKeyHex == _loadedGroupScopeKeyHex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_loadGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _collapseContactsSearch(UiViewStateService viewState) {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
_searchDebounce = null;
|
||||||
|
_searchController.clear();
|
||||||
|
viewState.setContactsSearchText('');
|
||||||
|
viewState.setContactsSearchExpanded(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showGroupsUnavailableMessage(BuildContext context) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(context.l10n.common_loading)));
|
||||||
|
}
|
||||||
|
|
||||||
void _setupFrameListener() {
|
void _setupFrameListener() {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
// Listen for incoming text messages from the repeater
|
// Listen for incoming text messages from the repeater
|
||||||
@@ -375,31 +443,166 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
await showDisconnectDialog(context, connector);
|
await showDisconnectDialog(context, connector);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
ContactGroup? _selectedGroupForName(String selectedGroupName) {
|
||||||
return ContactsFilterMenu(
|
if (selectedGroupName == contactsAllGroupsValue) return null;
|
||||||
sortOption: _sortOption,
|
for (final group in _groups) {
|
||||||
typeFilter: _typeFilter,
|
if (group.name == selectedGroupName) return group;
|
||||||
showUnreadOnly: _showUnreadOnly,
|
}
|
||||||
onSortChanged: (value) {
|
return null;
|
||||||
setState(() {
|
}
|
||||||
_sortOption = value;
|
|
||||||
|
void _ensureValidSelectedGroup() {
|
||||||
|
final viewState = context.read<UiViewStateService>();
|
||||||
|
if (viewState.contactsSelectedGroupName == contactsAllGroupsValue) return;
|
||||||
|
final exists = _groups.any(
|
||||||
|
(group) => group.name == viewState.contactsSelectedGroupName,
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
viewState.setContactsSelectedGroupName(contactsAllGroupsValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _closeDropdownAndRun(BuildContext popupContext, VoidCallback action) {
|
||||||
|
final route = ModalRoute.of(popupContext);
|
||||||
|
if (route != null && route.isCurrent) {
|
||||||
|
Navigator.of(popupContext).pop();
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
action();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterButton(
|
||||||
|
BuildContext context,
|
||||||
|
UiViewStateService viewState,
|
||||||
|
) {
|
||||||
|
return ContactsFilterMenu(
|
||||||
|
sortOption: viewState.contactsSortOption,
|
||||||
|
typeFilter: viewState.contactsTypeFilter,
|
||||||
|
showUnreadOnly: viewState.contactsShowUnreadOnly,
|
||||||
|
onSortChanged: (value) {
|
||||||
|
viewState.setContactsSortOption(value);
|
||||||
},
|
},
|
||||||
onTypeFilterChanged: (value) {
|
onTypeFilterChanged: (value) {
|
||||||
setState(() {
|
viewState.setContactsTypeFilter(value);
|
||||||
_typeFilter = value;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onUnreadOnlyChanged: (value) {
|
onUnreadOnlyChanged: (value) {
|
||||||
setState(() {
|
viewState.setContactsShowUnreadOnly(value);
|
||||||
_showUnreadOnly = value;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onNewGroup: () => _showGroupEditor(context, connector.contacts),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGroupButton(
|
||||||
|
BuildContext context,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
UiViewStateService viewState,
|
||||||
|
List<Contact> contacts,
|
||||||
|
List<ContactGroup> sortedGroups,
|
||||||
|
) {
|
||||||
|
final canManageGroups = _hasGroupStoreScope(connector);
|
||||||
|
final selectedGroupName =
|
||||||
|
_selectedGroupForName(viewState.contactsSelectedGroupName)?.name ??
|
||||||
|
context.l10n.listFilter_all;
|
||||||
|
final double menuWidth = (MediaQuery.sizeOf(context).width - 16).clamp(
|
||||||
|
0.0,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
position: PopupMenuPosition.under,
|
||||||
|
constraints: BoxConstraints.tightFor(width: menuWidth),
|
||||||
|
onSelected: (String value) {
|
||||||
|
viewState.setContactsSelectedGroupName(value);
|
||||||
|
},
|
||||||
|
itemBuilder: (menuContext) => [
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
value: contactsAllGroupsValue,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(menuContext.l10n.listFilter_all),
|
||||||
|
IconButton(
|
||||||
|
tooltip: menuContext.l10n.contacts_newGroup,
|
||||||
|
icon: const Icon(Icons.group_add, size: 20),
|
||||||
|
onPressed: canManageGroups
|
||||||
|
? () => _closeDropdownAndRun(
|
||||||
|
menuContext,
|
||||||
|
() => _showGroupEditor(this.context, contacts),
|
||||||
|
)
|
||||||
|
: () => _closeDropdownAndRun(
|
||||||
|
menuContext,
|
||||||
|
() => _showGroupsUnavailableMessage(this.context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...sortedGroups.map((group) {
|
||||||
|
return PopupMenuItem<String>(
|
||||||
|
value: group.name,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(group.name, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: menuContext.l10n.contacts_editGroup,
|
||||||
|
icon: const Icon(Icons.edit, size: 20),
|
||||||
|
onPressed: canManageGroups
|
||||||
|
? () => _closeDropdownAndRun(
|
||||||
|
menuContext,
|
||||||
|
() => _showGroupEditor(
|
||||||
|
this.context,
|
||||||
|
contacts,
|
||||||
|
group: group,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: () => _closeDropdownAndRun(
|
||||||
|
menuContext,
|
||||||
|
() => _showGroupsUnavailableMessage(this.context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
tooltip: menuContext.l10n.contacts_deleteGroup,
|
||||||
|
icon: const Icon(Icons.delete, size: 20, color: Colors.red),
|
||||||
|
onPressed: canManageGroups
|
||||||
|
? () => _closeDropdownAndRun(
|
||||||
|
menuContext,
|
||||||
|
() => _confirmDeleteGroup(this.context, group),
|
||||||
|
)
|
||||||
|
: () => _closeDropdownAndRun(
|
||||||
|
menuContext,
|
||||||
|
() => _showGroupsUnavailableMessage(this.context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(selectedGroupName, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.arrow_drop_down),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final viewState = context.watch<UiViewStateService>();
|
||||||
final contacts = connector.contacts;
|
final contacts = connector.contacts;
|
||||||
final shouldShowStartupSpinner =
|
final shouldShowStartupSpinner =
|
||||||
contacts.isEmpty &&
|
contacts.isEmpty &&
|
||||||
@@ -421,92 +624,171 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
final filteredAndSorted = _filterAndSortContacts(
|
||||||
final filteredGroups = _showUnreadOnly
|
contacts,
|
||||||
? const <ContactGroup>[]
|
connector,
|
||||||
: _filterAndSortGroups(_groups, contacts);
|
viewState,
|
||||||
|
);
|
||||||
|
|
||||||
String hintText = "";
|
String hintText = "";
|
||||||
|
|
||||||
switch (_typeFilter) {
|
switch (viewState.contactsTypeFilter) {
|
||||||
case ContactTypeFilter.all:
|
case ContactTypeFilter.all:
|
||||||
hintText = context.l10n.contacts_searchContacts(
|
hintText = context.l10n.contacts_searchContacts(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContactTypeFilter.users:
|
case ContactTypeFilter.users:
|
||||||
hintText = context.l10n.contacts_searchUsers(
|
hintText = context.l10n.contacts_searchUsers(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContactTypeFilter.repeaters:
|
case ContactTypeFilter.repeaters:
|
||||||
hintText = context.l10n.contacts_searchRepeaters(
|
hintText = context.l10n.contacts_searchRepeaters(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContactTypeFilter.rooms:
|
case ContactTypeFilter.rooms:
|
||||||
hintText = context.l10n.contacts_searchRoomServers(
|
hintText = context.l10n.contacts_searchRoomServers(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ContactTypeFilter.favorites:
|
case ContactTypeFilter.favorites:
|
||||||
hintText = context.l10n.contacts_searchFavorites(
|
hintText = context.l10n.contacts_searchFavorites(
|
||||||
filteredAndSorted.length,
|
filteredAndSorted.length,
|
||||||
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
viewState.contactsShowUnreadOnly
|
||||||
|
? " ${context.l10n.contacts_unread}"
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final groupsByName = <String, ContactGroup>{};
|
||||||
|
for (final group in _groups) {
|
||||||
|
groupsByName.putIfAbsent(group.name, () => group);
|
||||||
|
}
|
||||||
|
final sortedGroups = groupsByName.values.toList()
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
|
||||||
|
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||||
|
final searchExpandedWidth = (screenWidth * 0.52).clamp(
|
||||||
|
97.0,
|
||||||
|
double.infinity,
|
||||||
|
); // allow expansion up to 52% of screen width, but not less than the collapsed width
|
||||||
|
final searchCollapsedWidth = (screenWidth * 0.22).clamp(
|
||||||
|
97.0,
|
||||||
|
120.0,
|
||||||
|
); //two 48px icon buttons + 1px divider
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: TextField(
|
child: Row(
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: hintText,
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
|
||||||
suffixIcon: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
if (_searchQuery.isNotEmpty)
|
Expanded(
|
||||||
IconButton(
|
child: _buildGroupButton(
|
||||||
icon: const Icon(Icons.clear),
|
context,
|
||||||
onPressed: () {
|
connector,
|
||||||
_searchController.clear();
|
viewState,
|
||||||
setState(() {
|
contacts,
|
||||||
_searchQuery = '';
|
sortedGroups,
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
_buildFilterButton(context, connector),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
const SizedBox(width: 8),
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
width: viewState.contactsSearchExpanded
|
||||||
|
? searchExpandedWidth
|
||||||
|
: searchCollapsedWidth,
|
||||||
|
height: 48,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: viewState.contactsSearchExpanded
|
||||||
|
? TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
border: InputBorder.none,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 12,
|
||||||
vertical: 12,
|
vertical: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
_searchDebounce?.cancel();
|
_searchDebounce?.cancel();
|
||||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
_searchDebounce = Timer(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
context
|
||||||
_searchQuery = value.toLowerCase();
|
.read<UiViewStateService>()
|
||||||
});
|
.setContactsSearchText(value);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (viewState.contactsSearchExpanded) {
|
||||||
|
_collapseContactsSearch(viewState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewState.setContactsSearchExpanded(true);
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
viewState.contactsSearchExpanded
|
||||||
|
? Icons.close
|
||||||
|
: Icons.search,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 1,
|
||||||
|
height: 24,
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: _buildFilterButton(context, viewState),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
child: filteredAndSorted.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -514,7 +796,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_showUnreadOnly
|
viewState.contactsShowUnreadOnly
|
||||||
? context.l10n.contacts_noUnreadContacts
|
? context.l10n.contacts_noUnreadContacts
|
||||||
: context.l10n.contacts_noContactsFound,
|
: context.l10n.contacts_noContactsFound,
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
@@ -525,14 +807,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () => connector.getContacts(),
|
onRefresh: () => connector.getContacts(),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: filteredGroups.length + filteredAndSorted.length,
|
itemCount: filteredAndSorted.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index < filteredGroups.length) {
|
final contact = filteredAndSorted[index];
|
||||||
final group = filteredGroups[index];
|
|
||||||
return _buildGroupTile(context, group, contacts);
|
|
||||||
}
|
|
||||||
final contact =
|
|
||||||
filteredAndSorted[index - filteredGroups.length];
|
|
||||||
final unreadCount = connector.getUnreadCountForContact(
|
final unreadCount = connector.getUnreadCountForContact(
|
||||||
contact,
|
contact,
|
||||||
);
|
);
|
||||||
@@ -553,55 +830,26 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ContactGroup> _filterAndSortGroups(
|
|
||||||
List<ContactGroup> groups,
|
|
||||||
List<Contact> contacts,
|
|
||||||
) {
|
|
||||||
final query = _searchQuery.trim().toLowerCase();
|
|
||||||
final contactsByKey = <String, Contact>{};
|
|
||||||
for (final contact in contacts) {
|
|
||||||
contactsByKey[contact.publicKeyHex] = contact;
|
|
||||||
}
|
|
||||||
|
|
||||||
final filtered = groups
|
|
||||||
.where((group) {
|
|
||||||
if (query.isEmpty) return true;
|
|
||||||
if (group.name.toLowerCase().contains(query)) return true;
|
|
||||||
for (final key in group.memberKeys) {
|
|
||||||
final contact = contactsByKey[key];
|
|
||||||
if (contact != null && matchesContactQuery(contact, query)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.where((group) {
|
|
||||||
if (_typeFilter == ContactTypeFilter.all) return true;
|
|
||||||
// Groups don't have a favorite flag, so hide them under favorites filter
|
|
||||||
if (_typeFilter == ContactTypeFilter.favorites) return false;
|
|
||||||
for (final key in group.memberKeys) {
|
|
||||||
final contact = contactsByKey[key];
|
|
||||||
if (contact != null && _matchesTypeFilter(contact)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
filtered.sort(
|
|
||||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
|
||||||
);
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Contact> _filterAndSortContacts(
|
List<Contact> _filterAndSortContacts(
|
||||||
List<Contact> contacts,
|
List<Contact> contacts,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
|
UiViewStateService viewState,
|
||||||
) {
|
) {
|
||||||
var filtered = contacts.where((contact) {
|
var filtered = contacts.where((contact) {
|
||||||
if (_searchQuery.isEmpty) return true;
|
if (viewState.contactsSearchText.isEmpty) return true;
|
||||||
return matchesContactQuery(contact, _searchQuery);
|
return matchesContactQuery(contact, viewState.contactsSearchText);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
final selectedGroup = _selectedGroupForName(
|
||||||
|
viewState.contactsSelectedGroupName,
|
||||||
|
);
|
||||||
|
if (selectedGroup != null) {
|
||||||
|
final memberKeys = selectedGroup.memberKeys.toSet();
|
||||||
|
filtered = filtered
|
||||||
|
.where((contact) => memberKeys.contains(contact.publicKeyHex))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out own node from the list
|
// Filter out own node from the list
|
||||||
if (connector.selfPublicKey != null) {
|
if (connector.selfPublicKey != null) {
|
||||||
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
||||||
@@ -610,17 +858,22 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_typeFilter != ContactTypeFilter.all) {
|
if (viewState.contactsTypeFilter != ContactTypeFilter.all) {
|
||||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
filtered = filtered
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
_matchesTypeFilter(contact, viewState.contactsTypeFilter),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_showUnreadOnly) {
|
if (viewState.contactsShowUnreadOnly) {
|
||||||
filtered = filtered.where((contact) {
|
filtered = filtered.where((contact) {
|
||||||
return connector.getUnreadCountForContact(contact) > 0;
|
return connector.getUnreadCountForContact(contact) > 0;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (_sortOption) {
|
switch (viewState.contactsSortOption) {
|
||||||
case ContactSortOption.lastSeen:
|
case ContactSortOption.lastSeen:
|
||||||
filtered.sort(
|
filtered.sort(
|
||||||
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
||||||
@@ -649,8 +902,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesTypeFilter(Contact contact) {
|
bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) {
|
||||||
switch (_typeFilter) {
|
switch (typeFilter) {
|
||||||
case ContactTypeFilter.all:
|
case ContactTypeFilter.all:
|
||||||
return true;
|
return true;
|
||||||
case ContactTypeFilter.favorites:
|
case ContactTypeFilter.favorites:
|
||||||
@@ -671,57 +924,6 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
: contact.lastSeen;
|
: contact.lastSeen;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGroupTile(
|
|
||||||
BuildContext context,
|
|
||||||
ContactGroup group,
|
|
||||||
List<Contact> contacts,
|
|
||||||
) {
|
|
||||||
final memberContacts = _resolveGroupContacts(group, contacts);
|
|
||||||
final subtitle = _formatGroupMembers(context, memberContacts);
|
|
||||||
return ListTile(
|
|
||||||
leading: const CircleAvatar(
|
|
||||||
backgroundColor: Colors.teal,
|
|
||||||
child: Icon(Icons.group, color: Colors.white, size: 20),
|
|
||||||
),
|
|
||||||
title: Text(group.name),
|
|
||||||
subtitle: Text(subtitle),
|
|
||||||
trailing: Text(
|
|
||||||
memberContacts.length.toString(),
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
onTap: () => _showGroupOptions(context, group, contacts),
|
|
||||||
onLongPress: () => _showGroupOptions(context, group, contacts),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Contact> _resolveGroupContacts(
|
|
||||||
ContactGroup group,
|
|
||||||
List<Contact> contacts,
|
|
||||||
) {
|
|
||||||
final byKey = <String, Contact>{};
|
|
||||||
for (final contact in contacts) {
|
|
||||||
byKey[contact.publicKeyHex] = contact;
|
|
||||||
}
|
|
||||||
final resolved = <Contact>[];
|
|
||||||
for (final key in group.memberKeys) {
|
|
||||||
final contact = byKey[key];
|
|
||||||
if (contact != null) {
|
|
||||||
resolved.add(contact);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolved.sort(
|
|
||||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
|
||||||
);
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatGroupMembers(BuildContext context, List<Contact> members) {
|
|
||||||
if (members.isEmpty) return context.l10n.contacts_noMembers;
|
|
||||||
final names = members.map((c) => c.name).toList();
|
|
||||||
if (names.length <= 2) return names.join(', ');
|
|
||||||
return '${names.take(2).join(', ')} +${names.length - 2}';
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openChat(BuildContext context, Contact contact) {
|
void _openChat(BuildContext context, Contact contact) {
|
||||||
// Check if this is a repeater
|
// Check if this is a repeater
|
||||||
if (contact.type == advTypeRepeater) {
|
if (contact.type == advTypeRepeater) {
|
||||||
@@ -799,58 +1001,11 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showGroupOptions(
|
|
||||||
BuildContext context,
|
|
||||||
ContactGroup group,
|
|
||||||
List<Contact> contacts,
|
|
||||||
) {
|
|
||||||
final members = _resolveGroupContacts(group, contacts);
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (sheetContext) => SafeArea(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.edit),
|
|
||||||
title: Text(context.l10n.contacts_editGroup),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
_showGroupEditor(context, contacts, group: group);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.delete, color: Colors.red),
|
|
||||||
title: Text(
|
|
||||||
context.l10n.contacts_deleteGroup,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
_confirmDeleteGroup(context, group);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (members.isNotEmpty) const Divider(),
|
|
||||||
...members.map((member) {
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.person),
|
|
||||||
title: Text(member.name),
|
|
||||||
subtitle: Text(member.typeLabel),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
_openChat(context, member);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
|
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
|
||||||
|
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
|
||||||
|
_showGroupsUnavailableMessage(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
@@ -866,6 +1021,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
setState(() {
|
setState(() {
|
||||||
_groups.removeWhere((g) => g.name == group.name);
|
_groups.removeWhere((g) => g.name == group.name);
|
||||||
|
_ensureValidSelectedGroup();
|
||||||
});
|
});
|
||||||
await _saveGroups();
|
await _saveGroups();
|
||||||
},
|
},
|
||||||
@@ -884,6 +1040,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
List<Contact> contacts, {
|
List<Contact> contacts, {
|
||||||
ContactGroup? group,
|
ContactGroup? group,
|
||||||
}) {
|
}) {
|
||||||
|
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
|
||||||
|
_showGroupsUnavailableMessage(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final isEditing = group != null;
|
final isEditing = group != null;
|
||||||
final nameController = TextEditingController(text: group?.name ?? '');
|
final nameController = TextEditingController(text: group?.name ?? '');
|
||||||
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
||||||
@@ -910,6 +1070,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
),
|
),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -935,8 +1099,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
Expanded(
|
||||||
height: 240,
|
|
||||||
child: filteredContacts.isEmpty
|
child: filteredContacts.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -959,7 +1122,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
if (value == true) {
|
if (value == true) {
|
||||||
selectedKeys.add(contact.publicKeyHex);
|
selectedKeys.add(contact.publicKeyHex);
|
||||||
} else {
|
} else {
|
||||||
selectedKeys.remove(contact.publicKeyHex);
|
selectedKeys.remove(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -970,6 +1135,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
@@ -986,6 +1152,15 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (name.toLowerCase() ==
|
||||||
|
contactsAllGroupsValue.toLowerCase()) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.contacts_groupNameReserved),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final exists = _groups.any((g) {
|
final exists = _groups.any((g) {
|
||||||
if (isEditing && g.name == group.name) return false;
|
if (isEditing && g.name == group.name) return false;
|
||||||
return g.name.toLowerCase() == name.toLowerCase();
|
return g.name.toLowerCase() == name.toLowerCase();
|
||||||
@@ -1001,15 +1176,21 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final viewState = context.read<UiViewStateService>();
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
final index = _groups.indexWhere(
|
final index = _groups.indexWhere(
|
||||||
(g) => g.name == group.name,
|
(g) => g.name == group.name,
|
||||||
);
|
);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
|
final wasSelected =
|
||||||
|
viewState.contactsSelectedGroupName == group.name;
|
||||||
_groups[index] = ContactGroup(
|
_groups[index] = ContactGroup(
|
||||||
name: name,
|
name: name,
|
||||||
memberKeys: selectedKeys.toList(),
|
memberKeys: selectedKeys.toList(),
|
||||||
);
|
);
|
||||||
|
if (wasSelected) {
|
||||||
|
viewState.setContactsSelectedGroupName(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_groups.add(
|
_groups.add(
|
||||||
@@ -1018,7 +1199,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
memberKeys: selectedKeys.toList(),
|
memberKeys: selectedKeys.toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
viewState.setContactsSelectedGroupName(name);
|
||||||
}
|
}
|
||||||
|
_ensureValidSelectedGroup();
|
||||||
});
|
});
|
||||||
await _saveGroups();
|
await _saveGroups();
|
||||||
if (dialogContext.mounted) {
|
if (dialogContext.mounted) {
|
||||||
@@ -1056,7 +1239,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
if (isRepeater) ...[
|
if (isRepeater) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.radar, color: Colors.green),
|
leading: const Icon(Icons.radar, color: Colors.green),
|
||||||
title: contact.pathLength > 0
|
title: contact.pathBytesForDisplay.isNotEmpty
|
||||||
? Text(context.l10n.contacts_pathTrace)
|
? Text(context.l10n.contacts_pathTrace)
|
||||||
: Text(context.l10n.contacts_ping),
|
: Text(context.l10n.contacts_ping),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -1064,10 +1247,12 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => PathTraceMapScreen(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: contact.pathLength > 0
|
title: contact.pathBytesForDisplay.isNotEmpty
|
||||||
? context.l10n.contacts_repeaterPathTrace
|
? context.l10n.contacts_repeaterPathTrace
|
||||||
: context.l10n.contacts_repeaterPing,
|
: context.l10n.contacts_repeaterPing,
|
||||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
path: contact.pathBytesForDisplay,
|
||||||
|
flipPathAround: true,
|
||||||
|
targetContact: contact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1092,10 +1277,12 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => PathTraceMapScreen(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: contact.pathLength > 0
|
title: contact.pathBytesForDisplay.isNotEmpty
|
||||||
? context.l10n.contacts_roomPathTrace
|
? context.l10n.contacts_roomPathTrace
|
||||||
: context.l10n.contacts_roomPing,
|
: context.l10n.contacts_roomPing,
|
||||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
path: contact.pathBytesForDisplay,
|
||||||
|
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
|
||||||
|
targetContact: contact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1137,7 +1324,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
title: context.l10n.contacts_pathTraceTo(
|
title: context.l10n.contacts_pathTraceTo(
|
||||||
contact.name,
|
contact.name,
|
||||||
),
|
),
|
||||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
path: contact.pathBytesForDisplay,
|
||||||
|
flipPathAround: true,
|
||||||
targetContact: contact,
|
targetContact: contact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1165,7 +1353,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
await connector.setContactFavorite(contact, !isFavorite);
|
await connector.setContactFlags(
|
||||||
|
contact,
|
||||||
|
isFavorite: !isFavorite,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
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/discovery_contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../utils/contact_search.dart';
|
import '../utils/contact_search.dart';
|
||||||
import '../widgets/app_bar.dart';
|
import '../widgets/app_bar.dart';
|
||||||
import '../widgets/list_filter_widget.dart';
|
import '../widgets/list_filter_widget.dart';
|
||||||
@@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showContactContextMenu(
|
Future<void> _showContactContextMenu(
|
||||||
DiscoveryContact contact,
|
Contact contact,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
) async {
|
) async {
|
||||||
final action = await showModalBottomSheet<String>(
|
final action = await showModalBottomSheet<String>(
|
||||||
@@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
connector.importDiscoveredContact(contact);
|
connector.importDiscoveredContact(contact);
|
||||||
break;
|
break;
|
||||||
case 'copy_contact':
|
case 'copy_contact':
|
||||||
final hexString = pubKeyToHex(contact.rawPacket);
|
if (contact.rawPacket == null) return;
|
||||||
|
final hexString = pubKeyToHex(contact.rawPacket!);
|
||||||
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilters(
|
Widget _buildFilters(
|
||||||
List<DiscoveryContact> filteredAndSorted,
|
List<Contact> filteredAndSorted,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
) {
|
) {
|
||||||
String hintText = "";
|
String hintText = "";
|
||||||
@@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<DiscoveryContact> _filterAndSortContacts(
|
List<Contact> _filterAndSortContacts(
|
||||||
List<DiscoveryContact> contacts,
|
List<Contact> contacts,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
) {
|
) {
|
||||||
var filtered = contacts.where((contact) {
|
var filtered = contacts.where((contact) {
|
||||||
@@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesTypeFilter(DiscoveryContact contact) {
|
bool _matchesTypeFilter(Contact contact) {
|
||||||
switch (typeFilter) {
|
switch (typeFilter) {
|
||||||
case ContactTypeFilter.all:
|
case ContactTypeFilter.all:
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+98
-10
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MapScreenState extends State<MapScreen> {
|
class _MapScreenState extends State<MapScreen> {
|
||||||
static const double _labelZoomThreshold = 8.5;
|
// Zoom level at which node labels start to appear
|
||||||
|
static const double _labelZoomThreshold = 12.0;
|
||||||
|
|
||||||
final MapController _mapController = MapController();
|
final MapController _mapController = MapController();
|
||||||
final MapMarkerService _markerService = MapMarkerService();
|
final MapMarkerService _markerService = MapMarkerService();
|
||||||
@@ -91,6 +93,15 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _checkLocationPlausibility(double lat, double lon) {
|
||||||
|
const double epsilon = 1e-6;
|
||||||
|
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
|
||||||
|
lat >= -90.0 &&
|
||||||
|
lat <= 90.0 &&
|
||||||
|
lon >= -180.0 &&
|
||||||
|
lon <= 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
double _standardDeviation(List<double> values) {
|
double _standardDeviation(List<double> values) {
|
||||||
if (values.length <= 1) {
|
if (values.length <= 1) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -126,7 +137,12 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
builder: (context, connector, settingsService, pathHistory, child) {
|
builder: (context, connector, settingsService, pathHistory, child) {
|
||||||
final tileCache = context.read<MapTileCacheService>();
|
final tileCache = context.read<MapTileCacheService>();
|
||||||
final settings = settingsService.settings;
|
final settings = settingsService.settings;
|
||||||
final contacts = connector.contacts;
|
final allContacts = connector.allContacts;
|
||||||
|
|
||||||
|
final contacts = settings.mapShowDiscoveryContacts
|
||||||
|
? allContacts
|
||||||
|
: allContacts.where((c) => c.isActive).toList();
|
||||||
|
|
||||||
final highlightPosition = widget.highlightPosition;
|
final highlightPosition = widget.highlightPosition;
|
||||||
final sharedMarkers = settings.mapShowMarkers
|
final sharedMarkers = settings.mapShowMarkers
|
||||||
? _collectSharedMarkers(connector)
|
? _collectSharedMarkers(connector)
|
||||||
@@ -159,13 +175,13 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
: filteredByTime;
|
: filteredByTime;
|
||||||
|
|
||||||
// Filter by location
|
// Filter by location
|
||||||
final contactsWithLocation = filteredByKeyPrefix
|
final contactsWithLocation = filteredByKeyPrefix.where((c) {
|
||||||
.where((c) => c.hasLocation)
|
return c.hasLocation;
|
||||||
.toList();
|
}).toList();
|
||||||
|
|
||||||
// All contacts with a known location — used as anchors regardless of
|
// All contacts with a known location — used as anchors regardless of
|
||||||
// time/key-prefix filters so that repeaters are always available.
|
// time/key-prefix filters so that repeaters are always available.
|
||||||
final allContactsWithLocation = contacts
|
final allContactsWithLocation = allContacts
|
||||||
.where((c) => c.hasLocation)
|
.where((c) => c.hasLocation)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -468,7 +484,10 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_isBuildingPathTrace)
|
if (!_isBuildingPathTrace)
|
||||||
...guessedLocations.map(_buildGuessedMarker),
|
..._buildGuessedMarker(
|
||||||
|
guessedLocations,
|
||||||
|
showLabels: _showNodeLabels,
|
||||||
|
),
|
||||||
..._buildMarkers(
|
..._buildMarkers(
|
||||||
contactsWithLocation,
|
contactsWithLocation,
|
||||||
settings,
|
settings,
|
||||||
@@ -630,6 +649,13 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
anchors[0].latitude + offsetDeg * cos(angle),
|
anchors[0].latitude + offsetDeg * cos(angle),
|
||||||
anchors[0].longitude + offsetDeg * sin(angle),
|
anchors[0].longitude + offsetDeg * sin(angle),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!_checkLocationPlausibility(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
)) {
|
||||||
|
continue; // discard implausible guesses near (0, 0)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
double lat = 0, lon = 0;
|
double lat = 0, lon = 0;
|
||||||
for (final a in anchors) {
|
for (final a in anchors) {
|
||||||
@@ -637,6 +663,12 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
lon += a.longitude;
|
lon += a.longitude;
|
||||||
}
|
}
|
||||||
position = LatLng(lat / anchors.length, lon / anchors.length);
|
position = LatLng(lat / anchors.length, lon / anchors.length);
|
||||||
|
if (!_checkLocationPlausibility(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
)) {
|
||||||
|
continue; // discard implausible guesses near (0, 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.add(
|
result.add(
|
||||||
_GuessedLocation(
|
_GuessedLocation(
|
||||||
@@ -710,9 +742,15 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Marker _buildGuessedMarker(_GuessedLocation guess) {
|
List<Marker> _buildGuessedMarker(
|
||||||
|
List<_GuessedLocation> guessed, {
|
||||||
|
required bool showLabels,
|
||||||
|
}) {
|
||||||
|
final markers = <Marker>[];
|
||||||
|
|
||||||
|
for (final guess in guessed) {
|
||||||
final color = _getNodeColor(guess.contact.type);
|
final color = _getNodeColor(guess.contact.type);
|
||||||
return Marker(
|
final marker = Marker(
|
||||||
point: guess.position,
|
point: guess.position,
|
||||||
width: 35,
|
width: 35,
|
||||||
height: 35,
|
height: 35,
|
||||||
@@ -725,7 +763,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30),
|
color: color.withValues(
|
||||||
|
alpha: guess.highConfidence ? 0.55 : 0.30,
|
||||||
|
),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
@@ -744,6 +784,19 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
markers.add(marker);
|
||||||
|
|
||||||
|
if (showLabels) {
|
||||||
|
markers.add(
|
||||||
|
_buildNodeLabelMarker(
|
||||||
|
point: guess.position,
|
||||||
|
label: guess.contact.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Marker> _buildMarkers(
|
List<Marker> _buildMarkers(
|
||||||
@@ -1203,6 +1256,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
Contact contact, {
|
Contact contact, {
|
||||||
LatLng? guessedPosition,
|
LatLng? guessedPosition,
|
||||||
}) {
|
}) {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
@@ -1248,6 +1302,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
advTypeChat) // Only show chat button for chat nodes
|
advTypeChat) // Only show chat button for chat nodes
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (!contact.isActive) {
|
||||||
|
connector.importDiscoveredContact(contact);
|
||||||
|
}
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -1261,6 +1318,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
if (contact.type == advTypeRepeater)
|
if (contact.type == advTypeRepeater)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (!contact.isActive) {
|
||||||
|
connector.importDiscoveredContact(contact);
|
||||||
|
}
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
_showRepeaterLogin(context, contact);
|
_showRepeaterLogin(context, contact);
|
||||||
},
|
},
|
||||||
@@ -1269,6 +1329,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
if (contact.type == advTypeRoom)
|
if (contact.type == advTypeRoom)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (!contact.isActive) {
|
||||||
|
connector.importDiscoveredContact(contact);
|
||||||
|
}
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
_showRoomLogin(context, contact);
|
_showRoomLogin(context, contact);
|
||||||
},
|
},
|
||||||
@@ -1436,6 +1499,23 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.my_location),
|
||||||
|
title: Text(context.l10n.map_setAsMyLocation),
|
||||||
|
onTap: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final successMsg = context.l10n.settings_locationUpdated;
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
if (!connector.isConnected) return;
|
||||||
|
await connector.setNodeLocation(
|
||||||
|
lat: position.latitude,
|
||||||
|
lon: position.longitude,
|
||||||
|
);
|
||||||
|
await connector.refreshDeviceInfo();
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.close),
|
leading: const Icon(Icons.close),
|
||||||
title: Text(context.l10n.common_cancel),
|
title: Text(context.l10n.common_cancel),
|
||||||
@@ -1745,6 +1825,14 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
},
|
},
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text(context.l10n.map_showDiscoveryContacts),
|
||||||
|
value: settings.mapShowDiscoveryContacts,
|
||||||
|
onChanged: (value) {
|
||||||
|
service.setMapShowDiscoveryContacts(value ?? true);
|
||||||
|
},
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.map_keyPrefix,
|
context.l10n.map_keyPrefix,
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
|||||||
PathSelection? _pendingStatusSelection;
|
PathSelection? _pendingStatusSelection;
|
||||||
List<Map<String, dynamic>>? _parsedNeighbors;
|
List<Map<String, dynamic>>? _parsedNeighbors;
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -124,12 +142,11 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
|||||||
|
|
||||||
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
|
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
|
||||||
final buffer = BufferReader(frame);
|
final buffer = BufferReader(frame);
|
||||||
|
final contacts = connector.allContacts;
|
||||||
try {
|
try {
|
||||||
final neighborCount = buffer.readUInt16LE();
|
final neighborCount = buffer.readUInt16LE();
|
||||||
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
||||||
connector.contacts.where((c) => c.type == advTypeRepeater).forEach((
|
contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
|
||||||
repeater,
|
|
||||||
) {
|
|
||||||
for (var neighborData in parsedNeighbors) {
|
for (var neighborData in parsedNeighbors) {
|
||||||
final publicKey = neighborData['publicKey'];
|
final publicKey = neighborData['publicKey'];
|
||||||
if (listEquals(
|
if (listEquals(
|
||||||
@@ -164,13 +181,6 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
|
||||||
return connector.contacts.firstWhere(
|
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadNeighbors() async {
|
Future<void> _loadNeighbors() async {
|
||||||
if (_commandService == null) return;
|
if (_commandService == null) return;
|
||||||
|
|
||||||
|
|||||||
+109
-35
@@ -52,8 +52,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final Uint8List path;
|
final Uint8List path;
|
||||||
final int? repeaterId;
|
final int? repeaterId;
|
||||||
final bool flipPathRound;
|
final bool flipPathAround;
|
||||||
final bool reversePathRound;
|
final bool reversePathAround;
|
||||||
final Contact? targetContact;
|
final Contact? targetContact;
|
||||||
|
|
||||||
const PathTraceMapScreen({
|
const PathTraceMapScreen({
|
||||||
@@ -61,8 +61,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.path,
|
required this.path,
|
||||||
this.repeaterId,
|
this.repeaterId,
|
||||||
this.flipPathRound = false,
|
this.flipPathAround = false,
|
||||||
this.reversePathRound = false,
|
this.reversePathAround = false,
|
||||||
this.targetContact,
|
this.targetContact,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
ValueKey<String> _mapKey = const ValueKey('initial');
|
ValueKey<String> _mapKey = const ValueKey('initial');
|
||||||
double _pathDistanceMeters = 0.0;
|
double _pathDistanceMeters = 0.0;
|
||||||
bool _showNodeLabels = true;
|
bool _showNodeLabels = true;
|
||||||
|
Contact? _targetContact;
|
||||||
|
|
||||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||||
return pathBytes
|
return pathBytes
|
||||||
@@ -114,8 +115,30 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List addReturnPath(Uint8List pathBytes) {
|
Uint8List buildPath(Uint8List pathBytes) {
|
||||||
Uint8List? traceBytes;
|
Uint8List traceBytes;
|
||||||
|
|
||||||
|
if (pathBytes.isEmpty) {
|
||||||
|
traceBytes = Uint8List(1);
|
||||||
|
traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
|
||||||
|
return traceBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.targetContact?.type == advTypeRepeater ||
|
||||||
|
widget.targetContact?.type == advTypeRoom) {
|
||||||
|
final len = (pathBytes.length + pathBytes.length + 1);
|
||||||
|
traceBytes = Uint8List(len);
|
||||||
|
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
|
||||||
|
for (int i = 0; i < pathBytes.length; i++) {
|
||||||
|
traceBytes[i] = pathBytes[i];
|
||||||
|
if (i < pathBytes.length) {
|
||||||
|
traceBytes[len - 1 - i] = pathBytes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pathBytes.length < 2) {
|
||||||
|
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
|
||||||
|
}
|
||||||
final len = (pathBytes.length + pathBytes.length - 1);
|
final len = (pathBytes.length + pathBytes.length - 1);
|
||||||
traceBytes = Uint8List(len);
|
traceBytes = Uint8List(len);
|
||||||
for (int i = 0; i < pathBytes.length; i++) {
|
for (int i = 0; i < pathBytes.length; i++) {
|
||||||
@@ -124,6 +147,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
traceBytes[len - 1 - i] = pathBytes[i];
|
traceBytes[len - 1 - i] = pathBytes[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return traceBytes;
|
return traceBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,17 +159,17 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final Uint8List path;
|
final pathTmp = widget.reversePathAround
|
||||||
|
|
||||||
Uint8List pathTmp = widget.reversePathRound
|
|
||||||
? Uint8List.fromList(widget.path.reversed.toList())
|
? Uint8List.fromList(widget.path.reversed.toList())
|
||||||
: widget.path;
|
: widget.path;
|
||||||
|
|
||||||
if (widget.flipPathRound) {
|
final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
|
||||||
path = addReturnPath(pathTmp);
|
|
||||||
} else {
|
appLogger.info(
|
||||||
path = pathTmp;
|
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
||||||
}
|
tag: 'PathTraceMapScreen',
|
||||||
|
noNotify: !mounted,
|
||||||
|
);
|
||||||
|
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final frame = buildTraceReq(
|
final frame = buildTraceReq(
|
||||||
@@ -235,10 +259,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<int, Contact> pathContacts = {};
|
Map<int, Contact> pathContacts = {};
|
||||||
|
final contacts = connector.allContacts;
|
||||||
connector.contacts.where((c) => c.type != advTypeChat).forEach((
|
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||||
repeater,
|
|
||||||
) {
|
|
||||||
for (var repeaterData in pathData) {
|
for (var repeaterData in pathData) {
|
||||||
if (listEquals(
|
if (listEquals(
|
||||||
repeater.publicKey.sublist(0, 1),
|
repeater.publicKey.sublist(0, 1),
|
||||||
@@ -283,18 +305,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
// Compute endpoint position for the target contact.
|
// Compute endpoint position for the target contact.
|
||||||
LatLng? targetPos;
|
LatLng? targetPos;
|
||||||
bool targetGuessed = false;
|
bool targetGuessed = false;
|
||||||
final target = widget.targetContact;
|
_targetContact = widget.targetContact;
|
||||||
if (target != null) {
|
|
||||||
if (target.hasLocation) {
|
if (_targetContact != null) {
|
||||||
targetPos = LatLng(target.latitude!, target.longitude!);
|
final tc = _targetContact!;
|
||||||
} else if (pathData.isNotEmpty) {
|
if (tc.hasLocation) {
|
||||||
|
targetPos = LatLng(tc.latitude!, tc.longitude!);
|
||||||
|
} else if (widget.path.length > 1) {
|
||||||
// Infer from the last hop: average GPS contacts sharing that hop.
|
// Infer from the last hop: average GPS contacts sharing that hop.
|
||||||
// For a round-trip path (flipPathRound), the target-side hop sits
|
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop
|
||||||
// in the middle of the symmetric sequence; .last is the local side.
|
// sits in the middle of the symmetric sequence; .last is the local side.
|
||||||
final lastHop = (widget.flipPathRound && pathData.length > 1)
|
final lastHop = widget.reversePathAround
|
||||||
? pathData[(pathData.length - 1) ~/ 2]
|
? widget.path.first
|
||||||
: pathData.last;
|
: widget.path.last;
|
||||||
final peers = connector.contacts
|
|
||||||
|
final peers = connector.allContacts
|
||||||
.where(
|
.where(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.hasLocation &&
|
c.hasLocation &&
|
||||||
@@ -310,12 +335,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
||||||
peers.length;
|
peers.length;
|
||||||
const offsetDeg = 0.003;
|
const offsetDeg = 0.003;
|
||||||
final angle = (target.publicKey[1] / 255.0) * 2 * pi;
|
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||||
targetPos = LatLng(
|
targetPos = LatLng(
|
||||||
lat + offsetDeg * cos(angle),
|
lat + offsetDeg * cos(angle),
|
||||||
lon + offsetDeg * sin(angle),
|
lon + offsetDeg * sin(angle),
|
||||||
);
|
);
|
||||||
targetGuessed = true;
|
targetGuessed = true;
|
||||||
|
} else if (inferredPositions.containsKey(lastHop)) {
|
||||||
|
final lat = inferredPositions[lastHop]!.latitude;
|
||||||
|
final lon = inferredPositions[lastHop]!.longitude;
|
||||||
|
const offsetDeg = 0.003;
|
||||||
|
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||||
|
targetPos = LatLng(
|
||||||
|
lat + offsetDeg * cos(angle),
|
||||||
|
lon + offsetDeg * sin(angle),
|
||||||
|
);
|
||||||
|
targetGuessed = true;
|
||||||
|
} else {
|
||||||
|
// As a last resort, just place it at the same position as the last hop.
|
||||||
|
final contact = pathContacts[lastHop];
|
||||||
|
if (contact != null && contact.hasLocation) {
|
||||||
|
const offsetDeg = 0.003;
|
||||||
|
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||||
|
targetPos = LatLng(
|
||||||
|
contact.latitude! + offsetDeg * cos(angle),
|
||||||
|
contact.longitude! + offsetDeg * sin(angle),
|
||||||
|
);
|
||||||
|
targetGuessed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +371,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
|
|
||||||
_points = <LatLng>[];
|
_points = <LatLng>[];
|
||||||
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||||
|
int hopLast = 0;
|
||||||
|
int hopLastLast = 0;
|
||||||
for (final hop in _traceData!.pathData) {
|
for (final hop in _traceData!.pathData) {
|
||||||
|
if (hop == hopLastLast && widget.flipPathAround) {
|
||||||
|
break; //skip duplicate hops in round-trip paths
|
||||||
|
}
|
||||||
final contact = _traceData!.pathContacts[hop];
|
final contact = _traceData!.pathContacts[hop];
|
||||||
if (contact != null && contact.hasLocation) {
|
if (contact != null && contact.hasLocation) {
|
||||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||||
@@ -332,8 +384,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
final inferred = inferredPositions[hop];
|
final inferred = inferredPositions[hop];
|
||||||
if (inferred != null) _points.add(inferred);
|
if (inferred != null) _points.add(inferred);
|
||||||
}
|
}
|
||||||
|
hopLastLast = hopLast;
|
||||||
|
hopLast = hop;
|
||||||
|
}
|
||||||
|
if (targetPos != null) {
|
||||||
|
if (_targetContact != null && _targetContact!.type == advTypeChat) {
|
||||||
|
_points.add(targetPos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (targetPos != null) _points.add(targetPos);
|
|
||||||
_polylines = _points.length > 1
|
_polylines = _points.length > 1
|
||||||
? [
|
? [
|
||||||
Polyline(
|
Polyline(
|
||||||
@@ -422,7 +480,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_hasData) _buildMapPathTrace(context, tileCache),
|
if (_hasData)
|
||||||
|
_buildMapPathTrace(context, tileCache, _targetContact),
|
||||||
if (_points.isEmpty &&
|
if (_points.isEmpty &&
|
||||||
!_hasData &&
|
!_hasData &&
|
||||||
!_isLoading &&
|
!_isLoading &&
|
||||||
@@ -451,17 +510,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
List<Marker> _buildHopMarkers(
|
List<Marker> _buildHopMarkers(
|
||||||
List<int> pathData, {
|
List<int> pathData, {
|
||||||
required bool showLabels,
|
required bool showLabels,
|
||||||
|
required Contact? target,
|
||||||
}) {
|
}) {
|
||||||
final markers = <Marker>[];
|
final markers = <Marker>[];
|
||||||
|
int hopLast = 0;
|
||||||
|
int hopLastLast = 0;
|
||||||
for (final hop in pathData) {
|
for (final hop in pathData) {
|
||||||
final contact = _traceData!.pathContacts[hop];
|
final contact = _traceData!.pathContacts[hop];
|
||||||
final inferred = _inferredHopPositions[hop];
|
final inferred = _inferredHopPositions[hop];
|
||||||
final hasGps = contact != null && contact.hasLocation;
|
final hasGps = contact != null && contact.hasLocation;
|
||||||
if (!hasGps && inferred == null) continue;
|
if (hop == hopLastLast && widget.flipPathAround) {
|
||||||
|
continue; //skip duplicate hops in round-trip paths
|
||||||
|
}
|
||||||
|
if (!hasGps && inferred == null) {
|
||||||
|
hopLastLast = hopLast;
|
||||||
|
hopLast = hop;
|
||||||
|
continue; //skip hops with no GPS and no inferred position
|
||||||
|
}
|
||||||
final point = hasGps
|
final point = hasGps
|
||||||
? LatLng(contact.latitude!, contact.longitude!)
|
? LatLng(contact.latitude!, contact.longitude!)
|
||||||
: inferred!;
|
: inferred!;
|
||||||
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||||
|
|
||||||
markers.add(
|
markers.add(
|
||||||
Marker(
|
Marker(
|
||||||
point: point,
|
point: point,
|
||||||
@@ -503,6 +573,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
hopLastLast = hopLast;
|
||||||
|
hopLast = hop;
|
||||||
}
|
}
|
||||||
|
|
||||||
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
||||||
@@ -552,9 +624,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
|
|
||||||
// Add target contact endpoint marker.
|
// Add target contact endpoint marker.
|
||||||
final targetPos = _targetContactPosition;
|
final targetPos = _targetContactPosition;
|
||||||
if (targetPos != null) {
|
if (targetPos != null && target != null && target.type == advTypeChat) {
|
||||||
final isGuessed = _targetContactIsGuessed;
|
final isGuessed = _targetContactIsGuessed;
|
||||||
final targetName = widget.targetContact?.name ?? '?';
|
final targetName = target.name;
|
||||||
markers.add(
|
markers.add(
|
||||||
Marker(
|
Marker(
|
||||||
point: targetPos,
|
point: targetPos,
|
||||||
@@ -690,6 +762,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
Widget _buildMapPathTrace(
|
Widget _buildMapPathTrace(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MapTileCacheService tileCache,
|
MapTileCacheService tileCache,
|
||||||
|
Contact? target,
|
||||||
) {
|
) {
|
||||||
return FlutterMap(
|
return FlutterMap(
|
||||||
key: _mapKey,
|
key: _mapKey,
|
||||||
@@ -728,6 +801,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
markers: _buildHopMarkers(
|
markers: _buildHopMarkers(
|
||||||
_traceData!.pathData,
|
_traceData!.pathData,
|
||||||
showLabels: _showNodeLabels,
|
showLabels: _showNodeLabels,
|
||||||
|
target: target,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -77,11 +77,22 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTextMessageResponse(Uint8List frame) {
|
void _handleTextMessageResponse(Uint8List frame) {
|
||||||
|
|||||||
@@ -205,8 +205,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) =>
|
builder: (context) => TelemetryScreen(contact: repeater),
|
||||||
TelemetryScreen(repeater: repeater, password: password),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,11 +129,22 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||||
|
|||||||
@@ -91,11 +91,22 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTextMessageResponse(Uint8List frame) {
|
void _handleTextMessageResponse(Uint8List frame) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
|
import 'tcp_screen.dart';
|
||||||
import 'usb_screen.dart';
|
import 'usb_screen.dart';
|
||||||
|
|
||||||
/// Screen for scanning and connecting to MeshCore devices
|
/// Screen for scanning and connecting to MeshCore devices
|
||||||
@@ -125,10 +126,14 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
connector.state == MeshCoreConnectionState.scanning;
|
connector.state == MeshCoreConnectionState.scanning;
|
||||||
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
||||||
final usbSupported = PlatformInfo.supportsUsbSerial;
|
final usbSupported = PlatformInfo.supportsUsbSerial;
|
||||||
|
final tcpSupported = !PlatformInfo.isWeb;
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
@@ -148,6 +153,18 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
label: Text(context.l10n.connectionChoiceUsbLabel),
|
label: Text(context.l10n.connectionChoiceUsbLabel),
|
||||||
),
|
),
|
||||||
if (usbSupported) const SizedBox(width: 12),
|
if (usbSupported) const SizedBox(width: 12),
|
||||||
|
if (tcpSupported)
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const TcpScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heroTag: 'scanner_tcp_action',
|
||||||
|
icon: const Icon(Icons.lan),
|
||||||
|
label: Text(context.l10n.connectionChoiceTcpLabel),
|
||||||
|
),
|
||||||
|
if (tcpSupported) const SizedBox(width: 12),
|
||||||
FloatingActionButton.extended(
|
FloatingActionButton.extended(
|
||||||
heroTag: 'scanner_ble_action',
|
heroTag: 'scanner_ble_action',
|
||||||
onPressed: isBluetoothOff
|
onPressed: isBluetoothOff
|
||||||
@@ -181,6 +198,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -287,10 +287,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.visibility_off_outlined),
|
leading: const Icon(Icons.visibility_off_outlined),
|
||||||
title: Text(l10n.settings_privacyMode),
|
title: Text(l10n.settings_privacy),
|
||||||
subtitle: Text(l10n.settings_privacyModeSubtitle),
|
subtitle: Text(l10n.settings_privacySubtitle),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _togglePrivacy(context, connector),
|
onTap: () => _privacySettings(context, connector),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -657,47 +657,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Text(l10n.settings_privacyMode),
|
|
||||||
content: Text(l10n.settings_privacyModeToggle),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(l10n.common_cancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
await connector.setPrivacyMode(true);
|
|
||||||
await connector.refreshDeviceInfo();
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l10n.settings_privacyModeEnabled)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(l10n.common_enable),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
await connector.setPrivacyMode(false);
|
|
||||||
await connector.refreshDeviceInfo();
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(l10n.settings_privacyModeDisabled)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(l10n.common_disable),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
|
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
connector.sendSelfAdvert(flood: true);
|
connector.sendSelfAdvert(flood: true);
|
||||||
@@ -977,6 +936,137 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _privacySettings(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
|
||||||
|
int telemetryMode = connector.telemetryModeBase;
|
||||||
|
int telemetryLocMode = connector.telemetryModeLoc;
|
||||||
|
int telemetryEnvMode = connector.telemetryModeEnv;
|
||||||
|
bool advertLocPolicy = connector.advertLocationPolicy == 0 ? false : true;
|
||||||
|
int multiAcks = connector.multiAcks;
|
||||||
|
|
||||||
|
final telemModeBase = [
|
||||||
|
DropdownMenuItem(value: teleModeDeny, child: Text(l10n.settings_denyAll)),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: teleModeAllowFlags,
|
||||||
|
child: Text(l10n.settings_allowByContact),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: teleModeAllowAll,
|
||||||
|
child: Text(l10n.settings_allowAll),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: Text(l10n.settings_privacy),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.settings_privacySettingsDescription),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FeatureToggleRow(
|
||||||
|
title: l10n.settings_advertLocation,
|
||||||
|
subtitle: l10n.settings_advertLocationSubtitle,
|
||||||
|
value: advertLocPolicy,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => advertLocPolicy = value);
|
||||||
|
advertLocPolicy = value;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: telemetryMode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.settings_telemetryBaseMode,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: telemModeBase,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setDialogState(() => telemetryMode = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: telemetryLocMode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.settings_telemetryLocationMode,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: telemModeBase,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setDialogState(() => telemetryLocMode = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: telemetryEnvMode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.settings_telemetryEnvironmentMode,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: telemModeBase,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setDialogState(() => telemetryEnvMode = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
l10n.settings_multiAck(multiAcks.toString()),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: multiAcks.toDouble(),
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
divisions: 2,
|
||||||
|
label: multiAcks.toString(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => multiAcks = value.round());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(l10n.common_cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await connector.setTelemetryModeBase(
|
||||||
|
telemetryMode,
|
||||||
|
telemetryLocMode,
|
||||||
|
telemetryEnvMode,
|
||||||
|
advertLocPolicy ? 1 : 0,
|
||||||
|
multiAcks,
|
||||||
|
);
|
||||||
|
await connector.refreshDeviceInfo();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.settings_telemetryModeUpdated)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(l10n.common_save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class _RadioSettingsDialog extends StatefulWidget {
|
class _RadioSettingsDialog extends StatefulWidget {
|
||||||
final MeshCoreConnector connector;
|
final MeshCoreConnector connector;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
|
import '../services/app_settings_service.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
|
import 'contacts_screen.dart';
|
||||||
|
import 'usb_screen.dart';
|
||||||
|
|
||||||
|
class TcpScreen extends StatefulWidget {
|
||||||
|
const TcpScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TcpScreen> createState() => _TcpScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TcpScreenState extends State<TcpScreen> {
|
||||||
|
late final TextEditingController _hostController;
|
||||||
|
late final TextEditingController _portController;
|
||||||
|
late final MeshCoreConnector _connector;
|
||||||
|
late final VoidCallback _connectionListener;
|
||||||
|
bool _navigatedToContacts = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_hostController = TextEditingController(
|
||||||
|
text: context.read<AppSettingsService>().settings.tcpServerAddress,
|
||||||
|
);
|
||||||
|
_portController = TextEditingController(
|
||||||
|
text: context.read<AppSettingsService>().settings.tcpServerPort > 0
|
||||||
|
? context.read<AppSettingsService>().settings.tcpServerPort.toString()
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
_connector = context.read<MeshCoreConnector>();
|
||||||
|
|
||||||
|
_connectionListener = () {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_connector.state == MeshCoreConnectionState.disconnected) {
|
||||||
|
_navigatedToContacts = false;
|
||||||
|
}
|
||||||
|
if (_connector.state == MeshCoreConnectionState.connected &&
|
||||||
|
_connector.isTcpTransportConnected &&
|
||||||
|
!_navigatedToContacts) {
|
||||||
|
context.read<AppSettingsService>().setTcpServerAddress(
|
||||||
|
_hostController.text,
|
||||||
|
);
|
||||||
|
context.read<AppSettingsService>().setTcpServerPort(
|
||||||
|
int.tryParse(_portController.text) ?? 0,
|
||||||
|
);
|
||||||
|
_navigatedToContacts = true;
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_connector.addListener(_connectionListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hostController.dispose();
|
||||||
|
_portController.dispose();
|
||||||
|
_connector.removeListener(_connectionListener);
|
||||||
|
if (!_navigatedToContacts &&
|
||||||
|
_connector.activeTransport == MeshCoreTransportType.tcp &&
|
||||||
|
_connector.state != MeshCoreConnectionState.disconnected) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
unawaited(_connector.disconnect(manual: true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
title: AdaptiveAppBarTitle(context.l10n.tcpScreenTitle),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
final isConnecting =
|
||||||
|
connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp;
|
||||||
|
final isButtonDisabled =
|
||||||
|
isConnecting ||
|
||||||
|
connector.state == MeshCoreConnectionState.scanning;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildStatusBar(context, connector),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _hostController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.tcpHostLabel,
|
||||||
|
hintText: context.l10n.tcpHostHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
enabled: !isConnecting,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _portController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.tcpPortLabel,
|
||||||
|
hintText: context.l10n.tcpPortHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
enabled: !isConnecting,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('tcp_connect_button'),
|
||||||
|
onPressed: isButtonDisabled ? null : _connectTcp,
|
||||||
|
icon: isConnecting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.lan),
|
||||||
|
label: Text(
|
||||||
|
isConnecting
|
||||||
|
? context.l10n.scanner_connecting
|
||||||
|
: context.l10n.common_connect,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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: [
|
||||||
|
if (PlatformInfo.supportsUsbSerial)
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => const UsbScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heroTag: 'tcp_usb_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
icon: const Icon(Icons.usb),
|
||||||
|
label: Text(context.l10n.connectionChoiceUsbLabel),
|
||||||
|
),
|
||||||
|
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
},
|
||||||
|
heroTag: 'tcp_ble_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
icon: const Icon(Icons.bluetooth),
|
||||||
|
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
String statusText;
|
||||||
|
Color statusColor;
|
||||||
|
|
||||||
|
if (connector.isTcpTransportConnected) {
|
||||||
|
statusText = l10n.scanner_connectedTo(
|
||||||
|
connector.activeTcpEndpoint ?? 'TCP',
|
||||||
|
);
|
||||||
|
statusColor = Colors.green;
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
statusText = l10n.tcpStatus_connectingTo(
|
||||||
|
'${_hostController.text}:${_portController.text}',
|
||||||
|
);
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
statusText = l10n.scanner_disconnecting;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
} else {
|
||||||
|
statusText = l10n.tcpStatus_notConnected;
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: statusColor.withValues(alpha: 0.1),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle, size: 12, color: statusColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
statusText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connectTcp() async {
|
||||||
|
if (_connector.state == MeshCoreConnectionState.connecting ||
|
||||||
|
_connector.state == MeshCoreConnectionState.connected ||
|
||||||
|
_connector.state == MeshCoreConnectionState.disconnecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final host = _hostController.text.trim();
|
||||||
|
final parsedPort = int.tryParse(_portController.text.trim());
|
||||||
|
if (host.isEmpty) {
|
||||||
|
_showError(context.l10n.tcpErrorHostRequired);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
|
||||||
|
_showError(context.l10n.tcpErrorPortInvalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _connector.connectTcp(host: host, port: parsedPort);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showError(_friendlyErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _friendlyErrorMessage(Object error) {
|
||||||
|
if (error is UnsupportedError) {
|
||||||
|
return context.l10n.tcpErrorUnsupported;
|
||||||
|
}
|
||||||
|
if (error is TimeoutException) {
|
||||||
|
return context.l10n.tcpErrorTimedOut;
|
||||||
|
}
|
||||||
|
if (error is StateError) {
|
||||||
|
return context.l10n.tcpConnectionFailed(error.message);
|
||||||
|
}
|
||||||
|
if (error is ArgumentError) {
|
||||||
|
return context.l10n.tcpConnectionFailed(
|
||||||
|
error.message?.toString() ?? error.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context.l10n.tcpConnectionFailed(error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,30 +10,22 @@ import '../connector/meshcore_connector.dart';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
|
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';
|
||||||
|
|
||||||
class TelemetryScreen extends StatefulWidget {
|
class TelemetryScreen extends StatefulWidget {
|
||||||
final Contact repeater;
|
final Contact contact;
|
||||||
final String password;
|
|
||||||
|
|
||||||
const TelemetryScreen({
|
const TelemetryScreen({super.key, required this.contact});
|
||||||
super.key,
|
|
||||||
required this.repeater,
|
|
||||||
required this.password,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TelemetryScreen> createState() => _TelemetryScreenState();
|
State<TelemetryScreen> createState() => _TelemetryScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TelemetryScreenState extends State<TelemetryScreen> {
|
class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||||
static const int _statusPayloadOffset = 8;
|
int _tagData = 0;
|
||||||
static const int _statusStatsSize = 52;
|
|
||||||
static const int _statusResponseBytes =
|
|
||||||
_statusPayloadOffset + _statusStatsSize;
|
|
||||||
Uint8List _tagData = Uint8List(4);
|
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
@@ -44,6 +36,26 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
PathSelection? _pendingStatusSelection;
|
PathSelection? _pendingStatusSelection;
|
||||||
List<Map<String, dynamic>>? _parsedTelemetry;
|
List<Map<String, dynamic>>? _parsedTelemetry;
|
||||||
|
|
||||||
|
int _tripTime = 0;
|
||||||
|
|
||||||
|
int _resolveContactIndex = -1;
|
||||||
|
|
||||||
|
Contact _resolveContact(MeshCoreConnector connector) {
|
||||||
|
if (_resolveContactIndex >= 0 &&
|
||||||
|
_resolveContactIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveContactIndex].publicKeyHex ==
|
||||||
|
widget.contact.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveContactIndex];
|
||||||
|
}
|
||||||
|
_resolveContactIndex = connector.contacts.indexWhere(
|
||||||
|
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
||||||
|
);
|
||||||
|
if (_resolveContactIndex == -1) {
|
||||||
|
return widget.contact;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveContactIndex];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -60,27 +72,62 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
// Listen for incoming text messages from the repeater
|
// Listen for incoming text messages from the repeater
|
||||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||||
if (frame.isEmpty) return;
|
if (frame.isEmpty) return;
|
||||||
|
final reader = BufferReader(frame);
|
||||||
if (frame[0] == respCodeSent) {
|
try {
|
||||||
_tagData = frame.sublist(2, 6);
|
final cmd = reader.readByte();
|
||||||
|
if (cmd == respCodeSent) {
|
||||||
|
reader.skipBytes(1); // Skip the reserved byte
|
||||||
|
_tagData = reader.readUInt32LE();
|
||||||
|
_tripTime = reader.readUInt32LE();
|
||||||
|
_statusTimeout?.cancel();
|
||||||
|
_statusTimeout = Timer(Duration(milliseconds: _tripTime), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_isLoaded = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.telemetry_requestTimeout),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_recordTelemetryResult(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a binary response
|
// Check if it's a binary response
|
||||||
if (frame[0] == pushCodeBinaryResponse &&
|
if (cmd == pushCodeBinaryResponse) {
|
||||||
listEquals(frame.sublist(2, 6), _tagData)) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_handleStatusResponse(frame.sublist(6));
|
reader.skipBytes(1); // Skip the reserved byte
|
||||||
|
if (reader.readUInt32LE() != _tagData) return;
|
||||||
|
_handleTelemetryResponse(reader.readRemainingBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a telemetry response (for chat contacts)
|
||||||
|
if (cmd == pushCodeTelemetryResponse) {
|
||||||
|
reader.skipBytes(1); // Skip the reserved byte
|
||||||
|
final pubkey = reader.readBytes(6);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!listEquals(widget.contact.publicKey.sublist(0, 6), pubkey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_handleTelemetryResponse(reader.readRemainingBytes());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.error('Error parsing incoming frame: $e');
|
||||||
|
// If parsing fails, ignore the frame
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleStatusResponse(Uint8List frame) {
|
void _handleTelemetryResponse(Uint8List frame) {
|
||||||
final parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
final parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
||||||
final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry);
|
final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry);
|
||||||
if (batteryMv != null) {
|
if (batteryMv != null) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
connector.updateRepeaterBatterySnapshot(
|
connector.updateRepeaterBatterySnapshot(
|
||||||
widget.repeater.publicKeyHex,
|
widget.contact.publicKeyHex,
|
||||||
batteryMv,
|
batteryMv,
|
||||||
source: 'telemetry',
|
source: 'telemetry',
|
||||||
);
|
);
|
||||||
@@ -105,13 +152,6 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
|
||||||
return connector.contacts.firstWhere(
|
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadTelemetry() async {
|
Future<void> _loadTelemetry() async {
|
||||||
if (_commandService == null) return;
|
if (_commandService == null) return;
|
||||||
|
|
||||||
@@ -121,41 +161,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final repeater = _resolveRepeater(connector);
|
final selection = await connector.preparePathForContactSend(
|
||||||
final selection = await connector.preparePathForContactSend(repeater);
|
_resolveContact(connector),
|
||||||
|
);
|
||||||
_pendingStatusSelection = selection;
|
_pendingStatusSelection = selection;
|
||||||
final frame = buildSendBinaryReq(
|
Uint8List frame;
|
||||||
repeater.publicKey,
|
if (widget.contact.type != advTypeChat) {
|
||||||
|
frame = buildSendBinaryReq(
|
||||||
|
widget.contact.publicKey,
|
||||||
payload: Uint8List.fromList([reqTypeGetTelemetry]),
|
payload: Uint8List.fromList([reqTypeGetTelemetry]),
|
||||||
);
|
);
|
||||||
await connector.sendFrame(frame);
|
} else {
|
||||||
|
frame = buildSendTelemetryReq(widget.contact.publicKey);
|
||||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
|
||||||
var messageBytes = frame.length >= _statusResponseBytes
|
|
||||||
? frame.length
|
|
||||||
: _statusResponseBytes;
|
|
||||||
if (messageBytes < maxFrameSize) {
|
|
||||||
messageBytes = maxFrameSize;
|
|
||||||
}
|
}
|
||||||
final timeoutMs = connector.calculateTimeout(
|
await connector.sendFrame(frame);
|
||||||
pathLength: pathLengthValue,
|
|
||||||
messageBytes: messageBytes,
|
|
||||||
);
|
|
||||||
_statusTimeout?.cancel();
|
|
||||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
_isLoaded = false;
|
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(context.l10n.telemetry_requestTimeout),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_recordStatusResult(false);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -173,12 +192,16 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _recordStatusResult(bool success) {
|
void _recordTelemetryResult(bool success) {
|
||||||
final selection = _pendingStatusSelection;
|
final selection = _pendingStatusSelection;
|
||||||
if (selection == null) return;
|
if (selection == null) return;
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final repeater = _resolveRepeater(connector);
|
connector.recordRepeaterPathResult(
|
||||||
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
widget.contact,
|
||||||
|
selection,
|
||||||
|
success,
|
||||||
|
null,
|
||||||
|
);
|
||||||
_pendingStatusSelection = null;
|
_pendingStatusSelection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +219,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final settings = context.watch<AppSettingsService>().settings;
|
final settings = context.watch<AppSettingsService>().settings;
|
||||||
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
|
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
|
||||||
final repeater = _resolveRepeater(connector);
|
final isFloodMode = widget.contact.pathOverride == -1;
|
||||||
final isFloodMode = repeater.pathOverride == -1;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -210,7 +232,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
repeater.name,
|
widget.contact.name,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
@@ -225,9 +247,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
tooltip: l10n.repeater_routingMode,
|
tooltip: l10n.repeater_routingMode,
|
||||||
onSelected: (mode) async {
|
onSelected: (mode) async {
|
||||||
if (mode == 'flood') {
|
if (mode == 'flood') {
|
||||||
await connector.setPathOverride(repeater, pathLen: -1);
|
await connector.setPathOverride(widget.contact, pathLen: -1);
|
||||||
} else {
|
} else {
|
||||||
await connector.setPathOverride(repeater, pathLen: null);
|
await connector.setPathOverride(widget.contact, pathLen: null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
@@ -283,7 +305,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
icon: const Icon(Icons.timeline),
|
icon: const Icon(Icons.timeline),
|
||||||
tooltip: l10n.repeater_pathManagement,
|
tooltip: l10n.repeater_pathManagement,
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
PathManagementDialog.show(context, contact: repeater),
|
PathManagementDialog.show(context, contact: widget.contact),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
@@ -437,7 +459,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final batteryMv =
|
final batteryMv =
|
||||||
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
|
connector.getRepeaterBatteryMillivolts(widget.contact.publicKeyHex) ??
|
||||||
(telemetryVolts == null ? null : (telemetryVolts * 1000).round());
|
(telemetryVolts == null ? null : (telemetryVolts * 1000).round());
|
||||||
if (batteryMv == null) return l10n.common_notAvailable;
|
if (batteryMv == null) return l10n.common_notAvailable;
|
||||||
final chemistry = _batteryChemistry();
|
final chemistry = _batteryChemistry();
|
||||||
@@ -449,7 +471,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
String _batteryChemistry() {
|
String _batteryChemistry() {
|
||||||
final settingsService = context.read<AppSettingsService>();
|
final settingsService = context.read<AppSettingsService>();
|
||||||
return settingsService.batteryChemistryForRepeater(
|
return settingsService.batteryChemistryForRepeater(
|
||||||
widget.repeater.publicKeyHex,
|
widget.contact.publicKeyHex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-12
@@ -12,6 +12,7 @@ import '../utils/usb_port_labels.dart';
|
|||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
import 'scanner_screen.dart';
|
import 'scanner_screen.dart';
|
||||||
|
import 'tcp_screen.dart';
|
||||||
|
|
||||||
class UsbScreen extends StatefulWidget {
|
class UsbScreen extends StatefulWidget {
|
||||||
const UsbScreen({super.key});
|
const UsbScreen({super.key});
|
||||||
@@ -107,16 +108,33 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
bottomNavigationBar: Consumer<MeshCoreConnector>(
|
bottomNavigationBar: Consumer<MeshCoreConnector>(
|
||||||
builder: (context, connector, child) {
|
builder: (context, connector, child) {
|
||||||
final isLoading = _isLoadingPorts;
|
final isLoading = _isLoadingPorts;
|
||||||
final showBle = PlatformInfo.isWeb ||
|
final showBle = true;
|
||||||
PlatformInfo.isAndroid ||
|
final showTcp = !PlatformInfo.isWeb;
|
||||||
PlatformInfo.isIOS;
|
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
|
if (showTcp)
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => const TcpScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heroTag: 'usb_tcp_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.lan),
|
||||||
|
label: Text(context.l10n.connectionChoiceTcpLabel),
|
||||||
|
),
|
||||||
|
if (showTcp && showBle) const SizedBox(width: 12),
|
||||||
if (showBle)
|
if (showBle)
|
||||||
FloatingActionButton.extended(
|
FloatingActionButton.extended(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -127,25 +145,33 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
heroTag: 'usb_ble_action',
|
heroTag: 'usb_ble_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
icon: const Icon(Icons.bluetooth),
|
icon: const Icon(Icons.bluetooth),
|
||||||
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
||||||
),
|
),
|
||||||
if (showBle) const SizedBox(width: 12),
|
if ((showTcp || showBle) && !_supportsHotPlug)
|
||||||
|
const SizedBox(width: 12),
|
||||||
if (!_supportsHotPlug)
|
if (!_supportsHotPlug)
|
||||||
FloatingActionButton.extended(
|
FloatingActionButton.extended(
|
||||||
onPressed: isLoading ? null : _loadPorts,
|
onPressed: isLoading ? null : _loadPorts,
|
||||||
heroTag: 'usb_refresh_action',
|
heroTag: 'usb_refresh_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
icon: isLoading
|
icon: isLoading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.refresh),
|
: const Icon(Icons.usb),
|
||||||
label: Text(context.l10n.repeater_refresh),
|
label: Text(context.l10n.scanner_scan),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -191,9 +217,18 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.circle, size: 12, color: statusColor),
|
Icon(Icons.circle, size: 12, color: statusColor),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
statusText,
|
statusText,
|
||||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -259,8 +294,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
),
|
),
|
||||||
subtitle: showRawName ? Text(rawName) : null,
|
subtitle: showRawName ? Text(rawName) : null,
|
||||||
trailing: ElevatedButton(
|
trailing: ElevatedButton(
|
||||||
onPressed:
|
onPressed: isConnecting ? null : () => _connectPort(port),
|
||||||
isConnecting ? null : () => _connectPort(port),
|
|
||||||
child: Text(l10n.common_connect),
|
child: Text(l10n.common_connect),
|
||||||
),
|
),
|
||||||
onTap: isConnecting ? null : () => _connectPort(port),
|
onTap: isConnecting ? null : () => _connectPort(port),
|
||||||
@@ -329,8 +363,10 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
if (_connector.state != MeshCoreConnectionState.disconnected) return;
|
if (_connector.state != MeshCoreConnectionState.disconnected) return;
|
||||||
|
|
||||||
final rawPortName = normalizeUsbPortName(port);
|
final rawPortName = normalizeUsbPortName(port);
|
||||||
appLogger.info('Connect tapped for $port (raw: $rawPortName)',
|
appLogger.info(
|
||||||
tag: 'UsbScreen');
|
'Connect tapped for $port (raw: $rawPortName)',
|
||||||
|
tag: 'UsbScreen',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _connector.connectUsb(portName: rawPortName);
|
await _connector.connectUsb(portName: rawPortName);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier {
|
|||||||
String message, {
|
String message, {
|
||||||
String tag = 'App',
|
String tag = 'App',
|
||||||
AppDebugLogLevel level = AppDebugLogLevel.info,
|
AppDebugLogLevel level = AppDebugLogLevel.info,
|
||||||
|
bool noNotify = false,
|
||||||
}) {
|
}) {
|
||||||
if (!_enabled && !kDebugMode) return;
|
if (!_enabled && !kDebugMode) return;
|
||||||
if (!_enabled) {
|
if (!_enabled) {
|
||||||
@@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier {
|
|||||||
_entries.removeRange(0, _entries.length - maxEntries);
|
_entries.removeRange(0, _entries.length - maxEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!noNotify) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// Also print to console for development
|
// Also print to console for development
|
||||||
debugPrint('[$tag] $message');
|
debugPrint('[$tag] $message');
|
||||||
}
|
}
|
||||||
|
|
||||||
void info(String message, {String tag = 'App'}) {
|
void info(String message, {String tag = 'App', bool noNotify = false}) {
|
||||||
log(message, tag: tag, level: AppDebugLogLevel.info);
|
log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify);
|
||||||
}
|
}
|
||||||
|
|
||||||
void warn(String message, {String tag = 'App'}) {
|
void warn(String message, {String tag = 'App', bool noNotify = false}) {
|
||||||
log(message, tag: tag, level: AppDebugLogLevel.warning);
|
log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify);
|
||||||
}
|
}
|
||||||
|
|
||||||
void error(String message, {String tag = 'App'}) {
|
void error(String message, {String tag = 'App', bool noNotify = false}) {
|
||||||
log(message, tag: tag, level: AppDebugLogLevel.error);
|
log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier {
|
|||||||
appLogger.setEnabled(value);
|
appLogger.setEnabled(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setMapShowDiscoveryContacts(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setBatteryChemistryForDevice(
|
Future<void> setBatteryChemistryForDevice(
|
||||||
String deviceId,
|
String deviceId,
|
||||||
String chemistry,
|
String chemistry,
|
||||||
@@ -178,4 +182,12 @@ class AppSettingsService extends ChangeNotifier {
|
|||||||
..remove(channelName);
|
..remove(channelName);
|
||||||
await updateSettings(_settings.copyWith(mutedChannels: updated));
|
await updateSettings(_settings.copyWith(mutedChannels: updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setTcpServerAddress(String value) async {
|
||||||
|
await updateSettings(_settings.copyWith(tcpServerAddress: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setTcpServerPort(int value) async {
|
||||||
|
await updateSettings(_settings.copyWith(tcpServerPort: value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class ChatTextScaleService extends ChangeNotifier {
|
|||||||
|
|
||||||
void _commitScale() {
|
void _commitScale() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
PrefsManager.instance.setDouble(_prefKey, _scale);
|
unawaited(PrefsManager.instance.setDouble(_prefKey, _scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
|
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
[]; // Rolling buffer of recent ACK hashes
|
[]; // Rolling buffer of recent ACK hashes
|
||||||
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
||||||
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||||
|
final Map<String, List<String>> _sendQueue =
|
||||||
|
{}; // contactPubKeyHex → ordered list of messageIds awaiting send
|
||||||
|
final Set<String> _activeMessages =
|
||||||
|
{}; // messageIds currently in-flight (sent/retrying)
|
||||||
|
final Set<String> _resolvedMessages =
|
||||||
|
{}; // messageIds already resolved (prevents double _onMessageResolved)
|
||||||
final Map<String, String> _expectedHashToMessageId =
|
final Map<String, String> _expectedHashToMessageId =
|
||||||
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||||
|
|
||||||
@@ -52,12 +58,13 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
Function(Message)? _updateMessageCallback;
|
Function(Message)? _updateMessageCallback;
|
||||||
Function(Contact)? _clearContactPathCallback;
|
Function(Contact)? _clearContactPathCallback;
|
||||||
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
||||||
Function(int, int)? _calculateTimeoutCallback;
|
Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
|
||||||
Uint8List? Function()? _getSelfPublicKeyCallback;
|
Uint8List? Function()? _getSelfPublicKeyCallback;
|
||||||
String Function(Contact, String)? _prepareContactOutboundTextCallback;
|
String Function(Contact, String)? _prepareContactOutboundTextCallback;
|
||||||
AppSettingsService? _appSettingsService;
|
AppSettingsService? _appSettingsService;
|
||||||
AppDebugLogService? _debugLogService;
|
AppDebugLogService? _debugLogService;
|
||||||
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
||||||
|
Function(String, int, int, int)? _onDeliveryObservedCallback;
|
||||||
|
|
||||||
MessageRetryService();
|
MessageRetryService();
|
||||||
|
|
||||||
@@ -67,12 +74,20 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
required Function(Message) updateMessageCallback,
|
required Function(Message) updateMessageCallback,
|
||||||
Function(Contact)? clearContactPathCallback,
|
Function(Contact)? clearContactPathCallback,
|
||||||
Function(Contact, Uint8List, int)? setContactPathCallback,
|
Function(Contact, Uint8List, int)? setContactPathCallback,
|
||||||
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
|
Function(int pathLength, int messageBytes, {String? contactKey})?
|
||||||
|
calculateTimeoutCallback,
|
||||||
Uint8List? Function()? getSelfPublicKeyCallback,
|
Uint8List? Function()? getSelfPublicKeyCallback,
|
||||||
String Function(Contact, String)? prepareContactOutboundTextCallback,
|
String Function(Contact, String)? prepareContactOutboundTextCallback,
|
||||||
AppSettingsService? appSettingsService,
|
AppSettingsService? appSettingsService,
|
||||||
AppDebugLogService? debugLogService,
|
AppDebugLogService? debugLogService,
|
||||||
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
||||||
|
Function(
|
||||||
|
String contactKey,
|
||||||
|
int pathLength,
|
||||||
|
int messageBytes,
|
||||||
|
int tripTimeMs,
|
||||||
|
)?
|
||||||
|
onDeliveryObservedCallback,
|
||||||
}) {
|
}) {
|
||||||
_sendMessageCallback = sendMessageCallback;
|
_sendMessageCallback = sendMessageCallback;
|
||||||
_addMessageCallback = addMessageCallback;
|
_addMessageCallback = addMessageCallback;
|
||||||
@@ -85,6 +100,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_appSettingsService = appSettingsService;
|
_appSettingsService = appSettingsService;
|
||||||
_debugLogService = debugLogService;
|
_debugLogService = debugLogService;
|
||||||
_recordPathResultCallback = recordPathResultCallback;
|
_recordPathResultCallback = recordPathResultCallback;
|
||||||
|
_onDeliveryObservedCallback = onDeliveryObservedCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute expected ACK hash using same algorithm as firmware:
|
/// Compute expected ACK hash using same algorithm as firmware:
|
||||||
@@ -156,7 +172,49 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_addMessageCallback!(contact.publicKeyHex, message);
|
_addMessageCallback!(contact.publicKeyHex, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _attemptSend(messageId);
|
// Queue per contact — only one message in-flight at a time to avoid
|
||||||
|
// overflowing the firmware's 8-entry expected_ack_table.
|
||||||
|
final contactKey = contact.publicKeyHex;
|
||||||
|
_sendQueue[contactKey] ??= [];
|
||||||
|
_sendQueue[contactKey]!.add(messageId);
|
||||||
|
|
||||||
|
if (!_activeMessages.any(
|
||||||
|
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
|
||||||
|
)) {
|
||||||
|
_sendNextForContact(contactKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendNextForContact(String contactKey) {
|
||||||
|
final queue = _sendQueue[contactKey];
|
||||||
|
if (queue == null) return;
|
||||||
|
|
||||||
|
// Drain stale entries iteratively instead of recursing.
|
||||||
|
while (queue.isNotEmpty) {
|
||||||
|
final messageId = queue.removeAt(0);
|
||||||
|
if (_pendingMessages.containsKey(messageId)) {
|
||||||
|
_activeMessages.add(messageId);
|
||||||
|
_attemptSend(messageId).catchError((e) {
|
||||||
|
debugPrint('_attemptSend threw for $messageId: $e');
|
||||||
|
final msg = _pendingMessages[messageId];
|
||||||
|
if (msg != null) {
|
||||||
|
final failed = msg.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failed;
|
||||||
|
_updateMessageCallback?.call(failed);
|
||||||
|
}
|
||||||
|
_onMessageResolved(messageId, contactKey);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Message was cancelled/cleaned up while queued — try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMessageResolved(String messageId, String contactKey) {
|
||||||
|
if (_resolvedMessages.contains(messageId)) return;
|
||||||
|
_resolvedMessages.add(messageId);
|
||||||
|
_activeMessages.remove(messageId);
|
||||||
|
_sendNextForContact(contactKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attemptSend(String messageId) async {
|
Future<void> _attemptSend(String messageId) async {
|
||||||
@@ -169,13 +227,11 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
// Use the path that was captured when the message was first sent
|
// Use the path that was captured when the message was first sent
|
||||||
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
||||||
if (message.pathLength != null && message.pathLength! < 0) {
|
if (message.pathLength != null && message.pathLength! < 0) {
|
||||||
// Flood mode - clear the path
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'Setting flood mode for retry attempt ${message.retryCount}',
|
'Setting flood mode for retry attempt ${message.retryCount}',
|
||||||
);
|
);
|
||||||
_clearContactPathCallback!(contact);
|
await _clearContactPathCallback!(contact);
|
||||||
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
||||||
// Specific path (including direct neighbor with pathLength=0)
|
|
||||||
final pathStr = message.pathBytes.isEmpty
|
final pathStr = message.pathBytes.isEmpty
|
||||||
? 'direct'
|
? 'direct'
|
||||||
: message.pathBytes
|
: message.pathBytes
|
||||||
@@ -192,6 +248,24 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-validate after async gap — a timer or ACK could have resolved/retried
|
||||||
|
// this message while we were awaiting the path callback.
|
||||||
|
final currentMessage = _pendingMessages[messageId];
|
||||||
|
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: message $messageId resolved during path sync, aborting',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the message was retried by a timer during our await, the retryCount
|
||||||
|
// will have advanced. Only proceed if it still matches the attempt we started.
|
||||||
|
if (currentMessage.retryCount != message.retryCount) {
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final attempt = message.retryCount.clamp(0, 3);
|
final attempt = message.retryCount.clamp(0, 3);
|
||||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
@@ -231,6 +305,15 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
|
|
||||||
if (_sendMessageCallback != null) {
|
if (_sendMessageCallback != null) {
|
||||||
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
||||||
|
} else {
|
||||||
|
// No send callback — message would be stuck forever. Fail it immediately.
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: no sendMessageCallback, failing message $messageId',
|
||||||
|
);
|
||||||
|
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failedMessage;
|
||||||
|
_updateMessageCallback?.call(failedMessage);
|
||||||
|
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +364,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||||
|
// Only match within a single contact's queue to avoid cross-contact mismatches.
|
||||||
if (messageId == null && allowQueueFallback) {
|
if (messageId == null && allowQueueFallback) {
|
||||||
_debugLogService?.warn(
|
_debugLogService?.warn(
|
||||||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||||
@@ -290,13 +374,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
||||||
);
|
);
|
||||||
|
|
||||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
// Search all contact queues so concurrent chats don't miss matches.
|
||||||
|
final queuesToSearch = _pendingMessageQueuePerContact;
|
||||||
|
|
||||||
|
for (var entry in queuesToSearch.entries) {
|
||||||
final contactKey = entry.key;
|
final contactKey = entry.key;
|
||||||
final queue = entry.value;
|
final queue = entry.value;
|
||||||
|
|
||||||
if (queue.isNotEmpty) {
|
// Drain stale entries until we find a valid one or exhaust the queue.
|
||||||
|
while (queue.isNotEmpty) {
|
||||||
final candidateMessageId = queue.removeAt(0);
|
final candidateMessageId = queue.removeAt(0);
|
||||||
|
|
||||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||||
messageId = candidateMessageId;
|
messageId = candidateMessageId;
|
||||||
contact = _pendingContacts[candidateMessageId];
|
contact = _pendingContacts[candidateMessageId];
|
||||||
@@ -304,21 +391,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
} else {
|
}
|
||||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||||
if (queue.isNotEmpty) {
|
|
||||||
final nextMessageId = queue.removeAt(0);
|
|
||||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
|
||||||
messageId = nextMessageId;
|
|
||||||
contact = _pendingContacts[nextMessageId];
|
|
||||||
debugPrint(
|
|
||||||
'Queue-based match (fallback): $ackHashHex → message $messageId',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (messageId != null) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,9 +433,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
|
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
|
||||||
int actualTimeout = timeoutMs;
|
|
||||||
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) {
|
|
||||||
int pathLengthValue;
|
int pathLengthValue;
|
||||||
if (selection != null) {
|
if (selection != null) {
|
||||||
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||||
@@ -369,14 +443,24 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
pathLengthValue = contact.pathLength;
|
pathLengthValue = contact.pathLength;
|
||||||
}
|
}
|
||||||
actualTimeout = _calculateTimeoutCallback!(
|
|
||||||
|
int actualTimeout = timeoutMs;
|
||||||
|
if (_calculateTimeoutCallback != null) {
|
||||||
|
final calculated = _calculateTimeoutCallback!(
|
||||||
pathLengthValue,
|
pathLengthValue,
|
||||||
message.text.length,
|
message.text.length,
|
||||||
|
contactKey: contact.publicKeyHex,
|
||||||
);
|
);
|
||||||
|
// calculateTimeout tries ML first, falls back to physics.
|
||||||
|
// Use calculated value if device didn't provide one, or if ML
|
||||||
|
// produced a tighter prediction than the device's estimate.
|
||||||
|
if (timeoutMs <= 0 || calculated < timeoutMs) {
|
||||||
|
actualTimeout = calculated;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final updatedMessage = message.copyWith(
|
final updatedMessage = message.copyWith(
|
||||||
status: MessageStatus.sent,
|
status: MessageStatus.sent,
|
||||||
@@ -463,22 +547,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
// Max retries reached - mark as failed
|
// Max retries reached - mark as failed
|
||||||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failedMessage;
|
||||||
// Move ACK hashes to history before removing
|
|
||||||
_moveAckHashesToHistory(messageId);
|
|
||||||
|
|
||||||
_pendingMessages.remove(messageId);
|
|
||||||
_pendingContacts.remove(messageId);
|
|
||||||
_pendingPathSelections.remove(messageId);
|
|
||||||
_timeoutTimers[messageId]?.cancel();
|
|
||||||
_timeoutTimers.remove(messageId);
|
|
||||||
|
|
||||||
// Clean up the queue entry for this contact
|
|
||||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
|
||||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
|
||||||
false) {
|
|
||||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should clear the path on max retry
|
// Check if we should clear the path on max retry
|
||||||
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||||
@@ -499,6 +568,30 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Message is done retrying — send next queued message for this contact
|
||||||
|
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||||
|
|
||||||
|
// Keep message in pending maps for 30s grace period so late ACKs
|
||||||
|
// can still match and update the message to delivered.
|
||||||
|
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
|
||||||
|
_moveAckHashesToHistory(messageId);
|
||||||
|
// Clean up ALL hash mappings for this message
|
||||||
|
_ackHashToMessageId.removeWhere(
|
||||||
|
(_, mapping) => mapping.messageId == messageId,
|
||||||
|
);
|
||||||
|
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||||
|
_pendingMessages.remove(messageId);
|
||||||
|
_pendingContacts.remove(messageId);
|
||||||
|
_pendingPathSelections.remove(messageId);
|
||||||
|
_timeoutTimers.remove(messageId);
|
||||||
|
_resolvedMessages.remove(messageId);
|
||||||
|
final contactKey = contact.publicKeyHex;
|
||||||
|
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
|
||||||
|
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
|
||||||
|
_pendingMessageQueuePerContact.remove(contactKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +687,15 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matchedMessageId != null) {
|
if (matchedMessageId != null) {
|
||||||
final message = _pendingMessages[matchedMessageId]!;
|
final message = _pendingMessages[matchedMessageId];
|
||||||
|
if (message == null) {
|
||||||
|
// Message was already cleaned up (e.g. grace period expired)
|
||||||
|
_ackHashToMessageId.remove(ackHashHex);
|
||||||
|
debugPrint(
|
||||||
|
'ACK matched $matchedMessageId but message already cleaned up',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final contact = _pendingContacts[matchedMessageId];
|
final contact = _pendingContacts[matchedMessageId];
|
||||||
final selection = _pendingPathSelections[matchedMessageId];
|
final selection = _pendingPathSelections[matchedMessageId];
|
||||||
|
|
||||||
@@ -616,12 +717,21 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
tripTimeMs: tripTimeMs,
|
tripTimeMs: tripTimeMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up ALL hash mappings for this message (from all retry attempts)
|
||||||
|
_ackHashToMessageId.removeWhere(
|
||||||
|
(_, mapping) => mapping.messageId == matchedMessageId,
|
||||||
|
);
|
||||||
|
_expectedHashToMessageId.removeWhere(
|
||||||
|
(_, msgId) => msgId == matchedMessageId,
|
||||||
|
);
|
||||||
|
|
||||||
// Move ACK hashes to history before removing
|
// Move ACK hashes to history before removing
|
||||||
_moveAckHashesToHistory(matchedMessageId);
|
_moveAckHashesToHistory(matchedMessageId);
|
||||||
|
|
||||||
_pendingMessages.remove(matchedMessageId);
|
_pendingMessages.remove(matchedMessageId);
|
||||||
_pendingContacts.remove(matchedMessageId);
|
_pendingContacts.remove(matchedMessageId);
|
||||||
_pendingPathSelections.remove(matchedMessageId);
|
_pendingPathSelections.remove(matchedMessageId);
|
||||||
|
_resolvedMessages.remove(matchedMessageId);
|
||||||
|
|
||||||
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
@@ -646,6 +756,17 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
true,
|
true,
|
||||||
tripTimeMs,
|
tripTimeMs,
|
||||||
);
|
);
|
||||||
|
if (_onDeliveryObservedCallback != null &&
|
||||||
|
tripTimeMs > 0 &&
|
||||||
|
message.pathLength != null) {
|
||||||
|
_onDeliveryObservedCallback!(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
message.pathLength!,
|
||||||
|
message.text.length,
|
||||||
|
tripTimeMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -783,6 +904,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_ackHistory.clear();
|
_ackHistory.clear();
|
||||||
_ackHashToMessageId.clear();
|
_ackHashToMessageId.clear();
|
||||||
_pendingMessageQueuePerContact.clear();
|
_pendingMessageQueuePerContact.clear();
|
||||||
|
_sendQueue.clear();
|
||||||
|
_activeMessages.clear();
|
||||||
|
_resolvedMessages.clear();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ class NotificationService {
|
|||||||
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
|
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
|
||||||
if (addr != null && addr.isNotEmpty) return true;
|
if (addr != null && addr.isNotEmpty) return true;
|
||||||
// Fallback: check the default socket for the current user.
|
// Fallback: check the default socket for the current user.
|
||||||
final uid = Platform.environment['UID'] ??
|
final uid = Platform.environment['UID'] ?? Platform.environment['EUID'];
|
||||||
Platform.environment['EUID'];
|
|
||||||
final path = '/run/user/${uid ?? '1000'}/bus';
|
final path = '/run/user/${uid ?? '1000'}/bus';
|
||||||
return File(path).existsSync();
|
return File(path).existsSync();
|
||||||
}
|
}
|
||||||
@@ -233,7 +232,9 @@ class NotificationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await _notifications.show(
|
await _notifications.show(
|
||||||
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
id: contactId != null
|
||||||
|
? 'advert:$contactId'.hashCode
|
||||||
|
: DateTime.now().millisecondsSinceEpoch,
|
||||||
title: _l10n.notification_newTypeDiscovered(contactType),
|
title: _l10n.notification_newTypeDiscovered(contactType),
|
||||||
body: contactName,
|
body: contactName,
|
||||||
notificationDetails: notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
@@ -332,6 +333,61 @@ class NotificationService {
|
|||||||
await _notifications.cancel(id: id);
|
await _notifications.cancel(id: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel the notification for a specific contact and update the app badge.
|
||||||
|
Future<void> clearContactNotification(
|
||||||
|
String contactId,
|
||||||
|
int totalUnreadCount,
|
||||||
|
) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
await _notifications.cancel(id: contactId.hashCode);
|
||||||
|
await _updateBadge(totalUnreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the notification for a specific channel and update the app badge.
|
||||||
|
Future<void> clearChannelNotification(
|
||||||
|
int channelIndex,
|
||||||
|
int totalUnreadCount,
|
||||||
|
) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
await _notifications.cancel(id: channelIndex.hashCode);
|
||||||
|
await _updateBadge(totalUnreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel advert notifications for the given contact public key hexes.
|
||||||
|
Future<void> clearAdvertNotifications(List<String> contactIds) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
for (final id in contactIds) {
|
||||||
|
await _notifications.cancel(id: 'advert:$id'.hashCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateBadge(int count) async {
|
||||||
|
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
|
||||||
|
// On Apple platforms, set the badge number directly via a silent update.
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: false,
|
||||||
|
presentSound: false,
|
||||||
|
presentBadge: true,
|
||||||
|
badgeNumber: count,
|
||||||
|
);
|
||||||
|
final details = NotificationDetails(
|
||||||
|
iOS: darwinDetails,
|
||||||
|
macOS: darwinDetails,
|
||||||
|
);
|
||||||
|
// Use a fixed ID so each update replaces the previous one.
|
||||||
|
await _notifications.show(
|
||||||
|
id: 'badge_update'.hashCode,
|
||||||
|
title: null,
|
||||||
|
body: null,
|
||||||
|
notificationDetails: details,
|
||||||
|
);
|
||||||
|
// Immediately cancel the silent notification so it doesn't appear in tray.
|
||||||
|
await _notifications.cancel(id: 'badge_update'.hashCode);
|
||||||
|
}
|
||||||
|
// On Android, badge count is derived from active notifications,
|
||||||
|
// so cancelling the specific notification above is sufficient.
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
// Public notification methods (rate limiting is enforced automatically)
|
// Public notification methods (rate limiting is enforced automatically)
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import '../models/delivery_observation.dart';
|
||||||
import '../models/path_history.dart';
|
import '../models/path_history.dart';
|
||||||
import '../storage/prefs_manager.dart';
|
import '../storage/prefs_manager.dart';
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ 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 _deliveryObservationsKey = 'delivery_observations';
|
||||||
|
|
||||||
Future<void> savePathHistory(
|
Future<void> savePathHistory(
|
||||||
String contactPubKeyHex,
|
String contactPubKeyHex,
|
||||||
@@ -122,4 +124,33 @@ class StorageService {
|
|||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
await prefs.remove(_repeaterPasswordsKey);
|
await prefs.remove(_repeaterPasswordsKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveDeliveryObservations(
|
||||||
|
List<DeliveryObservation> observations,
|
||||||
|
) async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
|
||||||
|
await prefs.setString(_deliveryObservationsKey, jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
final jsonStr = prefs.getString(_deliveryObservationsKey);
|
||||||
|
|
||||||
|
if (jsonStr == null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(jsonStr) as List;
|
||||||
|
return list
|
||||||
|
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearDeliveryObservations() async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
await prefs.remove(_deliveryObservationsKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export 'tcp_transport_service_native.dart'
|
||||||
|
if (dart.library.js_interop) 'tcp_transport_service_web.dart';
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
import 'usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
class TcpTransportService {
|
||||||
|
final StreamController<Uint8List> _frameController =
|
||||||
|
StreamController<Uint8List>.broadcast();
|
||||||
|
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
StreamSubscription<Uint8List>? _socketSubscription;
|
||||||
|
Socket? _socket;
|
||||||
|
AppDebugLogService? _debugLogService;
|
||||||
|
TcpTransportStatus _status = TcpTransportStatus.disconnected;
|
||||||
|
String? _activeHost;
|
||||||
|
int? _activePort;
|
||||||
|
Future<void> _pendingWrite = Future<void>.value();
|
||||||
|
int _connectGeneration = 0;
|
||||||
|
|
||||||
|
TcpTransportStatus get status => _status;
|
||||||
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
|
bool get isConnected => _status == TcpTransportStatus.connected;
|
||||||
|
String? get activeEndpoint => _activeHost == null || _activePort == null
|
||||||
|
? null
|
||||||
|
: '$_activeHost:$_activePort';
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLogService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({
|
||||||
|
required String host,
|
||||||
|
required int port,
|
||||||
|
Duration timeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
if (_status == TcpTransportStatus.connected ||
|
||||||
|
_status == TcpTransportStatus.connecting) {
|
||||||
|
throw StateError('TCP transport is already active');
|
||||||
|
}
|
||||||
|
final trimmedHost = host.trim();
|
||||||
|
if (trimmedHost.isEmpty) {
|
||||||
|
throw ArgumentError.value(host, 'host', 'Host cannot be empty');
|
||||||
|
}
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
throw ArgumentError.value(port, 'port', 'Port must be in 1..65535');
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = TcpTransportStatus.connecting;
|
||||||
|
final generation = ++_connectGeneration;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final socket = await Socket.connect(trimmedHost, port, timeout: timeout);
|
||||||
|
if (generation != _connectGeneration ||
|
||||||
|
_status != TcpTransportStatus.connecting) {
|
||||||
|
try {
|
||||||
|
await socket.close();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||||
|
_socket = socket;
|
||||||
|
_activeHost = trimmedHost;
|
||||||
|
_activePort = port;
|
||||||
|
_socketSubscription = socket.listen(
|
||||||
|
_handleSocketData,
|
||||||
|
onError: _handleSocketError,
|
||||||
|
onDone: _handleSocketDone,
|
||||||
|
);
|
||||||
|
_status = TcpTransportStatus.connected;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'TCP transport opened endpoint=$activeEndpoint',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await _cleanupFailedConnect();
|
||||||
|
_status = TcpTransportStatus.disconnected;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) async {
|
||||||
|
if (!isConnected || _socket == null) {
|
||||||
|
throw StateError('TCP transport is not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
final packet = wrapUsbSerialTxFrame(data);
|
||||||
|
_logFrameSummary('TCP TX frame', data);
|
||||||
|
|
||||||
|
final writeTask = _pendingWrite.then((_) async {
|
||||||
|
final socket = _socket;
|
||||||
|
if (!isConnected || socket == null) {
|
||||||
|
throw StateError('TCP transport is not connected');
|
||||||
|
}
|
||||||
|
socket.add(packet);
|
||||||
|
await socket.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
_pendingWrite = writeTask.catchError((_) {});
|
||||||
|
await writeTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
_connectGeneration += 1;
|
||||||
|
if (_status == TcpTransportStatus.disconnected) return;
|
||||||
|
|
||||||
|
final endpoint = activeEndpoint;
|
||||||
|
_status = TcpTransportStatus.disconnecting;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
_activeHost = null;
|
||||||
|
_activePort = null;
|
||||||
|
|
||||||
|
final subscription = _socketSubscription;
|
||||||
|
_socketSubscription = null;
|
||||||
|
await subscription?.cancel();
|
||||||
|
|
||||||
|
final socket = _socket;
|
||||||
|
_socket = null;
|
||||||
|
try {
|
||||||
|
await socket?.close();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
socket?.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_status = TcpTransportStatus.disconnected;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'TCP transport closed endpoint=${endpoint ?? 'unknown'}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
unawaited(disconnect().whenComplete(_closeFrameController));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanupFailedConnect() async {
|
||||||
|
final subscription = _socketSubscription;
|
||||||
|
_socketSubscription = null;
|
||||||
|
await subscription?.cancel();
|
||||||
|
final socket = _socket;
|
||||||
|
_socket = null;
|
||||||
|
try {
|
||||||
|
await socket?.close();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
socket?.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
_activeHost = null;
|
||||||
|
_activePort = null;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSocketData(Uint8List bytes) {
|
||||||
|
for (final packet in _frameDecoder.ingest(bytes)) {
|
||||||
|
if (!packet.isRxFrame) {
|
||||||
|
_debugLogService?.info(
|
||||||
|
'TCP ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_addFrame(packet.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSocketError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
_addFrameError(error, stackTrace);
|
||||||
|
unawaited(disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSocketDone() {
|
||||||
|
if (_status == TcpTransportStatus.disconnecting ||
|
||||||
|
_status == TcpTransportStatus.disconnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_addFrameError(StateError('TCP socket closed by remote endpoint'));
|
||||||
|
unawaited(disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrame(Uint8List payload) {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
_frameController.add(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
_frameController.addError(error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logFrameSummary(String prefix, Uint8List payload) {
|
||||||
|
final code = payload.isNotEmpty ? payload.first : -1;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'$prefix code=$code len=${payload.length}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeFrameController() async {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
await _frameController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TcpTransportStatus { disconnected, connecting, connected, disconnecting }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
|
||||||
|
class TcpTransportService {
|
||||||
|
AppDebugLogService? _debugLogService;
|
||||||
|
|
||||||
|
Stream<Uint8List> get frameStream => const Stream<Uint8List>.empty();
|
||||||
|
bool get isConnected => false;
|
||||||
|
String? get activeEndpoint => null;
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLogService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({
|
||||||
|
required String host,
|
||||||
|
required int port,
|
||||||
|
Duration timeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
_debugLogService?.warn(
|
||||||
|
'TCP transport requested on web for $host:$port',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
throw UnsupportedError('TCP transport is not supported on web.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) async {
|
||||||
|
throw UnsupportedError('TCP transport is not supported on web.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {}
|
||||||
|
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:ml_algo/ml_algo.dart';
|
||||||
|
import 'package:ml_dataframe/ml_dataframe.dart';
|
||||||
|
import '../models/delivery_observation.dart';
|
||||||
|
import 'storage_service.dart';
|
||||||
|
|
||||||
|
class _ContactStats {
|
||||||
|
int count = 0;
|
||||||
|
double _sum = 0;
|
||||||
|
|
||||||
|
void add(double ms) {
|
||||||
|
count++;
|
||||||
|
_sum += ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get mean => _sum / count;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeoutPredictionService extends ChangeNotifier {
|
||||||
|
final StorageService? _storage;
|
||||||
|
|
||||||
|
static const int minObservations = 10;
|
||||||
|
static const int maxObservations = 100;
|
||||||
|
static const int _retrainInterval = 5;
|
||||||
|
// 1.5x multiplier on raw prediction to account for variance in delivery
|
||||||
|
// times — tight enough to improve on worst-case physics, loose enough
|
||||||
|
// to avoid premature timeouts from model noise.
|
||||||
|
static const double _safetyMargin = 1.5;
|
||||||
|
static const int _minContactObservations = 10;
|
||||||
|
|
||||||
|
List<DeliveryObservation> _observations = [];
|
||||||
|
LinearRegressor? _model;
|
||||||
|
List<String> _activeFeatures = [];
|
||||||
|
int _observationsSinceLastTrain = 0;
|
||||||
|
final Map<String, _ContactStats> _contactStats = {};
|
||||||
|
Timer? _persistTimer;
|
||||||
|
|
||||||
|
TimeoutPredictionService(StorageService storage) : _storage = storage;
|
||||||
|
TimeoutPredictionService.noStorage() : _storage = null;
|
||||||
|
|
||||||
|
int get observationCount => _observations.length;
|
||||||
|
bool get hasModel => _model != null;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
_observations = await _storage?.loadDeliveryObservations() ?? [];
|
||||||
|
_rebuildContactStats();
|
||||||
|
|
||||||
|
if (_observations.length >= minObservations) {
|
||||||
|
_trainModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: initialized with ${_observations.length} observations, '
|
||||||
|
'model=${_model != null ? "ready" : "waiting for data"}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordObservation({
|
||||||
|
required String contactKey,
|
||||||
|
required int pathLength,
|
||||||
|
required int messageBytes,
|
||||||
|
required int tripTimeMs,
|
||||||
|
int secondsSinceLastRx = 0,
|
||||||
|
}) {
|
||||||
|
final observation = DeliveryObservation(
|
||||||
|
contactKey: contactKey,
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
secondsSinceLastRx: secondsSinceLastRx,
|
||||||
|
isFlood: pathLength < 0,
|
||||||
|
deliveryMs: tripTimeMs,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
_observations.add(observation);
|
||||||
|
if (_observations.length > maxObservations) {
|
||||||
|
_observations.removeAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
|
||||||
|
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
|
||||||
|
|
||||||
|
_observationsSinceLastTrain++;
|
||||||
|
if (_observationsSinceLastTrain >= _retrainInterval &&
|
||||||
|
_observations.length >= minObservations) {
|
||||||
|
_trainModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistTimer?.cancel();
|
||||||
|
_persistTimer = Timer(const Duration(seconds: 2), () {
|
||||||
|
_storage?.saveDeliveryObservations(_observations);
|
||||||
|
});
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
|
||||||
|
'(${_observations.length} total)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? predictTimeout({
|
||||||
|
String? contactKey,
|
||||||
|
required int pathLength,
|
||||||
|
required int messageBytes,
|
||||||
|
int secondsSinceLastRx = 0,
|
||||||
|
}) {
|
||||||
|
if (_model == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_activeFeatures.isEmpty) return null;
|
||||||
|
|
||||||
|
final allFeatures = {
|
||||||
|
'pathLength': pathLength.toDouble(),
|
||||||
|
'messageBytes': messageBytes.toDouble(),
|
||||||
|
'secSinceRx': secondsSinceLastRx.toDouble(),
|
||||||
|
'isFlood': pathLength < 0 ? 1.0 : 0.0,
|
||||||
|
};
|
||||||
|
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
|
||||||
|
|
||||||
|
final features = DataFrame(
|
||||||
|
[row],
|
||||||
|
headerExists: false,
|
||||||
|
header: _activeFeatures,
|
||||||
|
);
|
||||||
|
|
||||||
|
final prediction = _model!.predict(features);
|
||||||
|
final rawValue = prediction.rows.first.first;
|
||||||
|
var predictedMs = (rawValue is double)
|
||||||
|
? rawValue
|
||||||
|
: (rawValue as num).toDouble();
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: raw prediction=$predictedMs for '
|
||||||
|
'pathLength=$pathLength, messageBytes=$messageBytes, '
|
||||||
|
'features=$_activeFeatures',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity check: if prediction is negative or zero, fall back
|
||||||
|
if (predictedMs <= 0) return null;
|
||||||
|
|
||||||
|
// Blend with per-contact mean if enough data
|
||||||
|
if (contactKey != null) {
|
||||||
|
final stats = _contactStats[contactKey];
|
||||||
|
if (stats != null && stats.count >= _minContactObservations) {
|
||||||
|
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector clamps this between physics min/max bounds
|
||||||
|
final timeout = (predictedMs * _safetyMargin).ceil();
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: ML timeout ${timeout}ms '
|
||||||
|
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
|
||||||
|
);
|
||||||
|
return timeout;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('TimeoutPrediction: prediction failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trainModel() {
|
||||||
|
try {
|
||||||
|
// Build feature columns, then exclude any with zero variance
|
||||||
|
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
|
||||||
|
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
|
||||||
|
final allExtractors = <double Function(DeliveryObservation)>[
|
||||||
|
(o) => o.pathLength.toDouble(),
|
||||||
|
(o) => o.messageBytes.toDouble(),
|
||||||
|
(o) => o.secondsSinceLastRx.toDouble(),
|
||||||
|
(o) => o.isFlood ? 1.0 : 0.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
_activeFeatures = [];
|
||||||
|
for (var i = 0; i < allNames.length; i++) {
|
||||||
|
final values = _observations.map(allExtractors[i]).toSet();
|
||||||
|
if (values.length > 1) _activeFeatures.add(allNames[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeFeatures.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: no features with variance, skipping training',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final header = [..._activeFeatures, 'deliveryMs'];
|
||||||
|
final rows = _observations.map((o) {
|
||||||
|
final row = <double>[];
|
||||||
|
for (var i = 0; i < allNames.length; i++) {
|
||||||
|
if (_activeFeatures.contains(allNames[i])) {
|
||||||
|
row.add(allExtractors[i](o));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.add(o.deliveryMs.toDouble());
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = DataFrame([header, ...rows], headerExists: true);
|
||||||
|
|
||||||
|
_model = LinearRegressor(data, 'deliveryMs');
|
||||||
|
_observationsSinceLastTrain = 0;
|
||||||
|
|
||||||
|
// Log training summary with sample predictions
|
||||||
|
final avgMs =
|
||||||
|
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
|
||||||
|
_observations.length;
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: trained on ${_observations.length} observations '
|
||||||
|
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('TimeoutPrediction: training failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_persistTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuildContactStats() {
|
||||||
|
_contactStats.clear();
|
||||||
|
for (final obs in _observations) {
|
||||||
|
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
|
||||||
|
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../storage/prefs_manager.dart';
|
||||||
|
import '../utils/contact_search.dart';
|
||||||
|
|
||||||
|
const String contactsAllGroupsValue = '__all__';
|
||||||
|
|
||||||
|
enum ChannelSortOption { manual, name, latestMessages, unread }
|
||||||
|
|
||||||
|
class UiViewStateService extends ChangeNotifier {
|
||||||
|
static const _keyContactsSelectedGroupName = 'ui_contacts_selected_group';
|
||||||
|
static const _keyContactsSortOption = 'ui_contacts_sort_option';
|
||||||
|
static const _keyContactsShowUnreadOnly = 'ui_contacts_show_unread_only';
|
||||||
|
static const _keyContactsTypeFilter = 'ui_contacts_type_filter';
|
||||||
|
static const _keyChannelsSortOption = 'ui_channels_sort_option';
|
||||||
|
static const _keyChannelsSortIndexLegacy = 'ui_channels_sort_index';
|
||||||
|
|
||||||
|
String _contactsSelectedGroupName = contactsAllGroupsValue;
|
||||||
|
String _contactsSearchText = '';
|
||||||
|
bool _contactsSearchExpanded = false;
|
||||||
|
ContactSortOption _contactsSortOption = ContactSortOption.lastSeen;
|
||||||
|
bool _contactsShowUnreadOnly = false;
|
||||||
|
ContactTypeFilter _contactsTypeFilter = ContactTypeFilter.all;
|
||||||
|
|
||||||
|
String _channelsSearchText = '';
|
||||||
|
ChannelSortOption _channelsSortOption = ChannelSortOption.manual;
|
||||||
|
|
||||||
|
String get contactsSelectedGroupName => _contactsSelectedGroupName;
|
||||||
|
String get contactsSearchText => _contactsSearchText;
|
||||||
|
bool get contactsSearchExpanded => _contactsSearchExpanded;
|
||||||
|
ContactSortOption get contactsSortOption => _contactsSortOption;
|
||||||
|
bool get contactsShowUnreadOnly => _contactsShowUnreadOnly;
|
||||||
|
ContactTypeFilter get contactsTypeFilter => _contactsTypeFilter;
|
||||||
|
String get channelsSearchText => _channelsSearchText;
|
||||||
|
ChannelSortOption get channelsSortOption => _channelsSortOption;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
|
||||||
|
final selectedGroupName = prefs.getString(_keyContactsSelectedGroupName);
|
||||||
|
if (selectedGroupName != null && selectedGroupName.isNotEmpty) {
|
||||||
|
_contactsSelectedGroupName = selectedGroupName;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sortStr = prefs.getString(_keyContactsSortOption);
|
||||||
|
if (sortStr != null) {
|
||||||
|
_contactsSortOption = ContactSortOption.values.firstWhere(
|
||||||
|
(e) => e.name == sortStr,
|
||||||
|
orElse: () => ContactSortOption.lastSeen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_contactsShowUnreadOnly =
|
||||||
|
prefs.getBool(_keyContactsShowUnreadOnly) ?? false;
|
||||||
|
|
||||||
|
final typeStr = prefs.getString(_keyContactsTypeFilter);
|
||||||
|
if (typeStr != null) {
|
||||||
|
_contactsTypeFilter = ContactTypeFilter.values.firstWhere(
|
||||||
|
(e) => e.name == typeStr,
|
||||||
|
orElse: () => ContactTypeFilter.all,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelSortStr = prefs.getString(_keyChannelsSortOption);
|
||||||
|
if (channelSortStr != null) {
|
||||||
|
_channelsSortOption = ChannelSortOption.values.firstWhere(
|
||||||
|
(e) => e.name == channelSortStr,
|
||||||
|
orElse: () => ChannelSortOption.manual,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility for old persisted index format.
|
||||||
|
switch (prefs.getInt(_keyChannelsSortIndexLegacy) ?? 0) {
|
||||||
|
case 0:
|
||||||
|
_channelsSortOption = ChannelSortOption.manual;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
_channelsSortOption = ChannelSortOption.name;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
_channelsSortOption = ChannelSortOption.latestMessages;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
_channelsSortOption = ChannelSortOption.unread;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_channelsSortOption = ChannelSortOption.manual;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setContactsSelectedGroupName(String value) {
|
||||||
|
if (_contactsSelectedGroupName == value) return;
|
||||||
|
_contactsSelectedGroupName = value;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(
|
||||||
|
PrefsManager.instance.setString(_keyContactsSelectedGroupName, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setContactsSearchText(String value) {
|
||||||
|
if (_contactsSearchText == value) return;
|
||||||
|
_contactsSearchText = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setContactsSearchExpanded(bool value) {
|
||||||
|
if (_contactsSearchExpanded == value) return;
|
||||||
|
_contactsSearchExpanded = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setContactsSortOption(ContactSortOption value) {
|
||||||
|
if (_contactsSortOption == value) return;
|
||||||
|
_contactsSortOption = value;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(
|
||||||
|
PrefsManager.instance.setString(_keyContactsSortOption, value.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setContactsShowUnreadOnly(bool value) {
|
||||||
|
if (_contactsShowUnreadOnly == value) return;
|
||||||
|
_contactsShowUnreadOnly = value;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(PrefsManager.instance.setBool(_keyContactsShowUnreadOnly, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setContactsTypeFilter(ContactTypeFilter value) {
|
||||||
|
if (_contactsTypeFilter == value) return;
|
||||||
|
_contactsTypeFilter = value;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(
|
||||||
|
PrefsManager.instance.setString(_keyContactsTypeFilter, value.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setChannelsSearchText(String value) {
|
||||||
|
if (_channelsSearchText == value) return;
|
||||||
|
_channelsSearchText = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setChannelsSortOption(ChannelSortOption value) {
|
||||||
|
if (_channelsSortOption == value) return;
|
||||||
|
_channelsSortOption = value;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(
|
||||||
|
PrefsManager.instance.setString(_keyChannelsSortOption, value.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,6 +189,10 @@ class UsbSerialService {
|
|||||||
serial.setStopBits1();
|
serial.setStopBits1();
|
||||||
serial.setFlowControlNone();
|
serial.setFlowControlNone();
|
||||||
serial.setRTS(false);
|
serial.setRTS(false);
|
||||||
|
// Toggle DTR low→high so the device sees a fresh connection even
|
||||||
|
// if the previous disconnect didn't cleanly signal DTR drop.
|
||||||
|
serial.setDTR(false);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
serial.setDTR(true);
|
serial.setDTR(true);
|
||||||
_serial = serial;
|
_serial = serial;
|
||||||
// Update the normalized port name to whichever candidate succeeded.
|
// Update the normalized port name to whichever candidate succeeded.
|
||||||
@@ -249,6 +253,21 @@ class UsbSerialService {
|
|||||||
_status = UsbSerialStatus.connected;
|
_status = UsbSerialStatus.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> writeRaw(Uint8List data) async {
|
||||||
|
if (!isConnected) {
|
||||||
|
throw StateError('USB serial port is not open');
|
||||||
|
}
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
try {
|
||||||
|
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
throw StateError(error.message ?? error.code);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_serial!.write(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> write(Uint8List data) async {
|
Future<void> write(Uint8List data) async {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
throw StateError('USB serial port is not open');
|
throw StateError('USB serial port is not open');
|
||||||
@@ -300,6 +319,7 @@ class UsbSerialService {
|
|||||||
_serial = null;
|
_serial = null;
|
||||||
try {
|
try {
|
||||||
if (serial?.isOpen() == FlOpenStatus.open) {
|
if (serial?.isOpen() == FlOpenStatus.open) {
|
||||||
|
serial?.setDTR(false);
|
||||||
serial?.closePort();
|
serial?.closePort();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -350,6 +370,7 @@ class UsbSerialService {
|
|||||||
final serial = _serial;
|
final serial = _serial;
|
||||||
try {
|
try {
|
||||||
if (serial?.isOpen() == FlOpenStatus.open) {
|
if (serial?.isOpen() == FlOpenStatus.open) {
|
||||||
|
serial?.setDTR(false);
|
||||||
serial?.closePort(); // synchronous C call — kills the SerialThread
|
serial?.closePort(); // synchronous C call — kills the SerialThread
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -118,10 +118,7 @@ class UsbSerialService {
|
|||||||
tag: 'USB Serial',
|
tag: 'USB Serial',
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_debugLogService?.error(
|
_debugLogService?.error('Web connect failed: $error', tag: 'USB Serial');
|
||||||
'Web connect failed: $error',
|
|
||||||
tag: 'USB Serial',
|
|
||||||
);
|
|
||||||
await _cleanupFailedConnect();
|
await _cleanupFailedConnect();
|
||||||
_status = UsbSerialStatus.disconnected;
|
_status = UsbSerialStatus.disconnected;
|
||||||
_connectedPortName = null;
|
_connectedPortName = null;
|
||||||
@@ -130,6 +127,17 @@ class UsbSerialService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> writeRaw(Uint8List data) async {
|
||||||
|
if (!isConnected || _writer == null) {
|
||||||
|
throw StateError('USB serial port is not open');
|
||||||
|
}
|
||||||
|
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
|
||||||
|
'write'.toJS,
|
||||||
|
data.toJS,
|
||||||
|
);
|
||||||
|
await promise.toDart;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> write(Uint8List data) async {
|
Future<void> write(Uint8List data) async {
|
||||||
if (!isConnected || _writer == null) {
|
if (!isConnected || _writer == null) {
|
||||||
throw StateError('USB serial port is not open');
|
throw StateError('USB serial port is not open');
|
||||||
@@ -268,9 +276,23 @@ class UsbSerialService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openPort(JSObject port, int baudRate) {
|
Future<void> _openPort(JSObject port, int baudRate) async {
|
||||||
final options = JSObject()..['baudRate'] = baudRate.toJS;
|
final options = JSObject()
|
||||||
return port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
..['baudRate'] = baudRate.toJS
|
||||||
|
..['flowControl'] = 'none'.toJS;
|
||||||
|
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
||||||
|
|
||||||
|
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
|
||||||
|
try {
|
||||||
|
final signals = JSObject()
|
||||||
|
..['dataTerminalReady'] = true.toJS
|
||||||
|
..['requestToSend'] = false.toJS;
|
||||||
|
await port
|
||||||
|
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
|
||||||
|
.toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// setSignals may not be supported on all browsers/devices.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cleanupFailedConnect() async {
|
Future<void> _cleanupFailedConnect() async {
|
||||||
@@ -324,8 +346,12 @@ class UsbSerialService {
|
|||||||
|
|
||||||
Future<void> _pumpReads() async {
|
Future<void> _pumpReads() async {
|
||||||
final reader = _reader;
|
final reader = _reader;
|
||||||
if (reader == null) return;
|
if (reader == null) {
|
||||||
|
_debugLogService?.warn('_pumpReads: reader is null', tag: 'USB Serial');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_debugLogService?.info('_pumpReads: started', tag: 'USB Serial');
|
||||||
try {
|
try {
|
||||||
while (_status == UsbSerialStatus.connected &&
|
while (_status == UsbSerialStatus.connected &&
|
||||||
identical(reader, _reader)) {
|
identical(reader, _reader)) {
|
||||||
@@ -333,6 +359,7 @@ class UsbSerialService {
|
|||||||
.callMethod<JSPromise<JSAny?>>('read'.toJS)
|
.callMethod<JSPromise<JSAny?>>('read'.toJS)
|
||||||
.toDart;
|
.toDart;
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
|
_debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
final resultObject = result as JSObject;
|
final resultObject = result as JSObject;
|
||||||
@@ -340,20 +367,27 @@ class UsbSerialService {
|
|||||||
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
|
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
|
||||||
final done = doneValue != null && doneValue.dartify() == true;
|
final done = doneValue != null && doneValue.dartify() == true;
|
||||||
if (done) {
|
if (done) {
|
||||||
|
_debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
final value = resultObject.getProperty<JSAny?>('value'.toJS);
|
final value = resultObject.getProperty<JSAny?>('value'.toJS);
|
||||||
final bytes = _coerceBytes(value);
|
final bytes = _coerceBytes(value);
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
if (bytes != null && bytes.isNotEmpty) {
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB RX raw: ${bytes.length} byte(s)',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
_ingestRawBytes(bytes);
|
_ingestRawBytes(bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
|
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
|
||||||
if (_status == UsbSerialStatus.connected) {
|
if (_status == UsbSerialStatus.connected) {
|
||||||
_addFrameError(error, stackTrace);
|
_addFrameError(error, stackTrace);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
|
||||||
_releaseLock(reader);
|
_releaseLock(reader);
|
||||||
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
||||||
_addFrameError(StateError('USB serial connection closed'));
|
_addFrameError(StateError('USB serial connection closed'));
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:meshcore_open/utils/app_logger.dart';
|
||||||
|
|
||||||
import '../models/channel_message.dart';
|
import '../models/channel_message.dart';
|
||||||
import '../helpers/smaz.dart';
|
import '../helpers/smaz.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
@@ -7,13 +9,25 @@ import 'prefs_manager.dart';
|
|||||||
class ChannelMessageStore {
|
class ChannelMessageStore {
|
||||||
static const String _keyPrefix = 'channel_messages_';
|
static const String _keyPrefix = 'channel_messages_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
/// Save messages for a specific channel
|
/// Save messages for a specific channel
|
||||||
Future<void> saveChannelMessages(
|
Future<void> saveChannelMessages(
|
||||||
int channelIndex,
|
int channelIndex,
|
||||||
List<ChannelMessage> messages,
|
List<ChannelMessage> messages,
|
||||||
) async {
|
) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot save channel messages.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
|
|
||||||
// Convert messages to JSON
|
// Convert messages to JSON
|
||||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
||||||
@@ -24,12 +38,35 @@ class ChannelMessageStore {
|
|||||||
|
|
||||||
/// Load messages for a specific channel
|
/// Load messages for a specific channel
|
||||||
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
|
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot load channel messages.',
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
|
final oldKey = '$_keyPrefix$channelIndex';
|
||||||
final jsonString = prefs.getString(key);
|
|
||||||
if (jsonString == null) return [];
|
|
||||||
|
|
||||||
|
String? jsonString = prefs.getString(key);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(oldKey);
|
||||||
|
prefs.remove(oldKey);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel messages from legacy key $oldKey to scoped key $key',
|
||||||
|
);
|
||||||
|
await prefs.setString(key, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
return jsonList.map((json) => _messageFromJson(json)).toList();
|
return jsonList.map((json) => _messageFromJson(json)).toList();
|
||||||
@@ -42,14 +79,14 @@ class ChannelMessageStore {
|
|||||||
/// Clear messages for a specific channel
|
/// Clear messages for a specific channel
|
||||||
Future<void> clearChannelMessages(int channelIndex) async {
|
Future<void> clearChannelMessages(int channelIndex) async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
await prefs.remove(key);
|
await prefs.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all channel messages
|
/// Clear all channel messages
|
||||||
Future<void> clearAllChannelMessages() async {
|
Future<void> clearAllChannelMessages() async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
|
final keys = prefs.getKeys().where((k) => k.startsWith(keyFor));
|
||||||
for (var key in keys) {
|
for (var key in keys) {
|
||||||
await prefs.remove(key);
|
await prefs.remove(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,49 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ChannelOrderStore {
|
class ChannelOrderStore {
|
||||||
static const String _key = 'channel_order';
|
static const String _keyPrefix = 'channel_order_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<void> saveChannelOrder(List<int> order) async {
|
Future<void> saveChannelOrder(List<int> order) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save channel order.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
await prefs.setString(_key, jsonEncode(order));
|
await prefs.setString(keyFor, jsonEncode(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<int>> loadChannelOrder() async {
|
Future<List<int>> loadChannelOrder() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load channel order.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final raw = prefs.getString(_key);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (raw == null || raw.isEmpty) return [];
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(raw);
|
final decoded = jsonDecode(jsonString);
|
||||||
if (decoded is List) {
|
if (decoded is List) {
|
||||||
return decoded
|
return decoded
|
||||||
.map((value) => value is int ? value : int.tryParse('$value'))
|
.map((value) => value is int ? value : int.tryParse('$value'))
|
||||||
@@ -24,7 +53,7 @@ class ChannelOrderStore {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// fall through to legacy parse
|
// fall through to legacy parse
|
||||||
}
|
}
|
||||||
return raw
|
return jsonString
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => int.tryParse(value))
|
.map((value) => int.tryParse(value))
|
||||||
.whereType<int>()
|
.whereType<int>()
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ChannelSettingsStore {
|
class ChannelSettingsStore {
|
||||||
static const String _smazKeyPrefix = 'channel_smaz_';
|
static const String _keyPrefix = 'channel_smaz_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<bool> loadSmazEnabled(int channelIndex) async {
|
Future<bool> loadSmazEnabled(int channelIndex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot load channel settings.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_smazKeyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
return prefs.getBool(key) ?? false;
|
final oldKey = '$_keyPrefix$channelIndex';
|
||||||
|
bool? enabled = prefs.getBool(oldKey);
|
||||||
|
if (enabled == null) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
enabled = prefs.getBool(oldKey);
|
||||||
|
prefs.remove(oldKey);
|
||||||
|
if (enabled != null) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel settings from legacy key $oldKey to scoped key $key',
|
||||||
|
);
|
||||||
|
await prefs.setBool(key, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
|
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot save channel settings.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_smazKeyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
await prefs.setBool(key, enabled);
|
await prefs.setBool(key, enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,46 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ChannelStore {
|
class ChannelStore {
|
||||||
static const String _key = 'channels';
|
static const String _keyPrefix = 'channels';
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<List<Channel>> loadChannels() async {
|
Future<List<Channel>> loadChannels() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load channels.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_key);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (jsonStr == null) return [];
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
return jsonList
|
return jsonList
|
||||||
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -23,9 +51,13 @@ class ChannelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveChannels(List<Channel> channels) async {
|
Future<void> saveChannels(List<Channel> channels) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save channels.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonList = channels.map(_toJson).toList();
|
final jsonList = channels.map(_toJson).toList();
|
||||||
await prefs.setString(_key, jsonEncode(jsonList));
|
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _toJson(Channel channel) {
|
Map<String, dynamic> _toJson(Channel channel) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../models/community.dart';
|
import '../models/community.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
/// Persists communities to local storage using SharedPreferences.
|
/// Persists communities to local storage using SharedPreferences.
|
||||||
@@ -9,12 +10,37 @@ import 'prefs_manager.dart';
|
|||||||
/// Each community contains its secret K, so this data should
|
/// Each community contains its secret K, so this data should
|
||||||
/// be considered sensitive (though device encryption handles security).
|
/// be considered sensitive (though device encryption handles security).
|
||||||
class CommunityStore {
|
class CommunityStore {
|
||||||
static const String _communitiesKey = 'communities_v1';
|
static const String _keyPrefix = 'communities_v1';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
/// Load all communities from storage
|
/// Load all communities from storage
|
||||||
Future<List<Community>> loadCommunities() async {
|
Future<List<Community>> loadCommunities() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load communities.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonString = prefs.getString(_communitiesKey);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -32,9 +58,13 @@ class CommunityStore {
|
|||||||
|
|
||||||
/// Save all communities to storage
|
/// Save all communities to storage
|
||||||
Future<void> saveCommunities(List<Community> communities) async {
|
Future<void> saveCommunities(List<Community> communities) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save communities.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonList = communities.map((c) => c.toJson()).toList();
|
final jsonList = communities.map((c) => c.toJson()).toList();
|
||||||
await prefs.setString(_communitiesKey, jsonEncode(jsonList));
|
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a new community
|
/// Add a new community
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../models/discovery_contact.dart';
|
import '../models/contact.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ContactDiscoveryStore {
|
class ContactDiscoveryStore {
|
||||||
static const String _key = 'discovered_contacts';
|
static const String _keyPrefix = 'discovered_contacts';
|
||||||
|
|
||||||
Future<List<DiscoveryContact>> loadContacts() async {
|
Future<List<Contact>> loadContacts() async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_key);
|
final jsonStr = prefs.getString(_keyPrefix);
|
||||||
if (jsonStr == null) return [];
|
if (jsonStr == null) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -22,40 +22,62 @@ class ContactDiscoveryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveContacts(List<DiscoveryContact> contacts) async {
|
Future<void> saveContacts(List<Contact> contacts) async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonList = contacts.map(_toJson).toList();
|
final jsonList = contacts.map(_toJson).toList();
|
||||||
await prefs.setString(_key, jsonEncode(jsonList));
|
await prefs.setString(_keyPrefix, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _toJson(DiscoveryContact contact) {
|
Map<String, dynamic> _toJson(Contact contact) {
|
||||||
return {
|
return {
|
||||||
'rawPacket': base64Encode(contact.rawPacket),
|
|
||||||
'publicKey': base64Encode(contact.publicKey),
|
'publicKey': base64Encode(contact.publicKey),
|
||||||
'name': contact.name,
|
'name': contact.name,
|
||||||
'type': contact.type,
|
'type': contact.type,
|
||||||
|
'flags': contact.flags,
|
||||||
'pathLength': contact.pathLength,
|
'pathLength': contact.pathLength,
|
||||||
'path': base64Encode(contact.path),
|
'path': base64Encode(contact.path),
|
||||||
|
'pathOverride': contact.pathOverride,
|
||||||
|
'pathOverrideBytes': contact.pathOverrideBytes != null
|
||||||
|
? base64Encode(contact.pathOverrideBytes!)
|
||||||
|
: null,
|
||||||
'latitude': contact.latitude,
|
'latitude': contact.latitude,
|
||||||
'longitude': contact.longitude,
|
'longitude': contact.longitude,
|
||||||
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
||||||
|
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
||||||
|
'rawPacket': contact.rawPacket != null
|
||||||
|
? base64Encode(contact.rawPacket!)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryContact _fromJson(Map<String, dynamic> json) {
|
Contact _fromJson(Map<String, dynamic> json) {
|
||||||
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
||||||
return DiscoveryContact(
|
final lastMessageMs = json['lastMessageAt'] as int?;
|
||||||
rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)),
|
return Contact(
|
||||||
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
||||||
name: json['name'] as String? ?? 'Unknown',
|
name: json['name'] as String? ?? 'Unknown',
|
||||||
type: json['type'] as int? ?? 0,
|
type: json['type'] as int? ?? 0,
|
||||||
|
flags: json['flags'] as int? ?? 0,
|
||||||
pathLength: json['pathLength'] as int? ?? -1,
|
pathLength: json['pathLength'] as int? ?? -1,
|
||||||
path: json['path'] != null
|
path: json['path'] != null
|
||||||
? Uint8List.fromList(base64Decode(json['path'] as String))
|
? Uint8List.fromList(base64Decode(json['path'] as String))
|
||||||
: Uint8List(0),
|
: Uint8List(0),
|
||||||
|
pathOverride: json['pathOverride'] as int?,
|
||||||
|
pathOverrideBytes: json['pathOverrideBytes'] != null
|
||||||
|
? Uint8List.fromList(
|
||||||
|
base64Decode(json['pathOverrideBytes'] as String),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
||||||
|
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
lastMessageMs ?? lastSeenMs,
|
||||||
|
),
|
||||||
|
isActive: false,
|
||||||
|
rawPacket: json['rawPacket'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import '../models/contact_group.dart';
|
import '../models/contact_group.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ContactGroupStore {
|
class ContactGroupStore {
|
||||||
static const String _key = 'contact_groups';
|
static const String _keyPrefix = 'contact_groups';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<List<ContactGroup>> loadGroups() async {
|
Future<List<ContactGroup>> loadGroups() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load contact groups.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final raw = prefs.getString(_key);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (raw == null || raw.isEmpty) return [];
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(raw);
|
final decoded = jsonDecode(jsonString);
|
||||||
if (decoded is List) {
|
if (decoded is List) {
|
||||||
return decoded
|
return decoded
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
@@ -25,8 +53,12 @@ class ContactGroupStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveGroups(List<ContactGroup> groups) async {
|
Future<void> saveGroups(List<ContactGroup> groups) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save contact groups.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
|
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
|
||||||
await prefs.setString(_key, encoded);
|
await prefs.setString(keyFor, encoded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ContactSettingsStore {
|
class ContactSettingsStore {
|
||||||
static const String _smazKeyPrefix = 'contact_smaz_';
|
static const String _keyPrefix = 'contact_smaz_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<bool> loadSmazEnabled(String contactKeyHex) async {
|
Future<bool> loadSmazEnabled(String contactKeyHex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot load contact settings.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_smazKeyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
|
final oldKey = '$_keyPrefix$contactKeyHex';
|
||||||
|
bool? enabled = prefs.getBool(key);
|
||||||
|
if (enabled == null) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
enabled = prefs.getBool(oldKey);
|
||||||
|
prefs.remove(oldKey);
|
||||||
|
if (enabled != null) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating contact settings from legacy key $oldKey to scoped key $key',
|
||||||
|
);
|
||||||
|
await prefs.setBool(key, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
return prefs.getBool(key) ?? false;
|
return prefs.getBool(key) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
|
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot save contact settings.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_smazKeyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
await prefs.setBool(key, enabled);
|
await prefs.setBool(key, enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,46 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ContactStore {
|
class ContactStore {
|
||||||
static const String _key = 'contacts';
|
static const String _keyPrefix = 'contacts';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<List<Contact>> loadContacts() async {
|
Future<List<Contact>> loadContacts() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load contacts.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_key);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (jsonStr == null) return [];
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
return jsonList
|
return jsonList
|
||||||
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -23,9 +51,13 @@ class ContactStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveContacts(List<Contact> contacts) async {
|
Future<void> saveContacts(List<Contact> contacts) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save contacts.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonList = contacts.map(_toJson).toList();
|
final jsonList = contacts.map(_toJson).toList();
|
||||||
await prefs.setString(_key, jsonEncode(jsonList));
|
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _toJson(Contact contact) {
|
Map<String, dynamic> _toJson(Contact contact) {
|
||||||
@@ -44,6 +76,10 @@ class ContactStore {
|
|||||||
'longitude': contact.longitude,
|
'longitude': contact.longitude,
|
||||||
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
||||||
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
||||||
|
'isActive': contact.isActive,
|
||||||
|
'rawPacket': contact.rawPacket != null
|
||||||
|
? base64Encode(contact.rawPacket!)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +107,10 @@ class ContactStore {
|
|||||||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
lastMessageMs ?? lastSeenMs,
|
lastMessageMs ?? lastSeenMs,
|
||||||
),
|
),
|
||||||
|
isActive: json['isActive'] as bool? ?? true,
|
||||||
|
rawPacket: json['rawPacket'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,59 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../helpers/smaz.dart';
|
import '../helpers/smaz.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class MessageStore {
|
class MessageStore {
|
||||||
static const String _keyPrefix = 'messages_';
|
static const String _keyPrefix = 'messages_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<void> saveMessages(
|
Future<void> saveMessages(
|
||||||
String contactKeyHex,
|
String contactKeyHex,
|
||||||
List<Message> messages,
|
List<Message> messages,
|
||||||
) async {
|
) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save messages.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
final jsonList = messages.map(_messageToJson).toList();
|
final jsonList = messages.map(_messageToJson).toList();
|
||||||
await prefs.setString(key, jsonEncode(jsonList));
|
await prefs.setString(key, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Message>> loadMessages(String contactKeyHex) async {
|
Future<List<Message>> loadMessages(String contactKeyHex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load messages.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
final jsonString = prefs.getString(key);
|
final oldKey = '$_keyPrefix$contactKeyHex';
|
||||||
if (jsonString == null) return [];
|
String? jsonString = prefs.getString(key);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(oldKey);
|
||||||
|
prefs.remove(oldKey);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating messages from legacy key $oldKey to scoped key $key',
|
||||||
|
);
|
||||||
|
await prefs.setString(key, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
@@ -32,8 +65,12 @@ class MessageStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearMessages(String contactKeyHex) async {
|
Future<void> clearMessages(String contactKeyHex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot clear messages.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
await prefs.remove(key);
|
await prefs.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
/// Storage for unread message tracking with debounced writes to reduce I/O.
|
/// Storage for unread message tracking with debounced writes to reduce I/O.
|
||||||
class UnreadStore {
|
class UnreadStore {
|
||||||
static const String _contactUnreadCountKey = 'contact_unread_count';
|
static const String _keyPrefix = 'contact_unread_count';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
// Debounce timers to batch rapid writes
|
// Debounce timers to batch rapid writes
|
||||||
Timer? _contactUnreadSaveTimer;
|
Timer? _contactUnreadSaveTimer;
|
||||||
@@ -20,12 +27,33 @@ class UnreadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, int>> loadContactUnreadCount() async {
|
Future<Map<String, int>> loadContactUnreadCount() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load unread counts.');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_contactUnreadCountKey);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (jsonStr == null) return {};
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
final json = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||||
return json.map((key, value) => MapEntry(key, value as int));
|
return json.map((key, value) => MapEntry(key, value as int));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return {};
|
return {};
|
||||||
@@ -33,6 +61,10 @@ class UnreadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void saveContactUnreadCount(Map<String, int> counts) {
|
void saveContactUnreadCount(Map<String, int> counts) {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save unread counts.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
_pendingContactUnreadCount = counts;
|
_pendingContactUnreadCount = counts;
|
||||||
|
|
||||||
_contactUnreadSaveTimer?.cancel();
|
_contactUnreadSaveTimer?.cancel();
|
||||||
@@ -49,7 +81,7 @@ class UnreadStore {
|
|||||||
|
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = jsonEncode(_pendingContactUnreadCount);
|
final jsonStr = jsonEncode(_pendingContactUnreadCount);
|
||||||
await prefs.setString(_contactUnreadCountKey, jsonStr);
|
await prefs.setString(keyFor, jsonStr);
|
||||||
_pendingContactUnreadCount = null;
|
_pendingContactUnreadCount = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,23 +23,23 @@ class AppLogger {
|
|||||||
bool get isEnabled => _enabled;
|
bool get isEnabled => _enabled;
|
||||||
|
|
||||||
/// Log an info message
|
/// Log an info message
|
||||||
void info(String message, {String tag = 'App'}) {
|
void info(String message, {String tag = 'App', bool noNotify = false}) {
|
||||||
if (_enabled && _service != null) {
|
if (_enabled && _service != null) {
|
||||||
_service!.info(message, tag: tag);
|
_service!.info(message, tag: tag, noNotify: noNotify);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log a warning message
|
/// Log a warning message
|
||||||
void warn(String message, {String tag = 'App'}) {
|
void warn(String message, {String tag = 'App', bool noNotify = false}) {
|
||||||
if (_enabled && _service != null) {
|
if (_enabled && _service != null) {
|
||||||
_service!.warn(message, tag: tag);
|
_service!.warn(message, tag: tag, noNotify: noNotify);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log an error message
|
/// Log an error message
|
||||||
void error(String message, {String tag = 'App'}) {
|
void error(String message, {String tag = 'App', bool noNotify = false}) {
|
||||||
if (_enabled && _service != null) {
|
if (_enabled && _service != null) {
|
||||||
_service!.error(message, tag: tag);
|
_service!.error(message, tag: tag, noNotify: noNotify);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +48,10 @@ class AppLogger {
|
|||||||
String message, {
|
String message, {
|
||||||
String tag = 'App',
|
String tag = 'App',
|
||||||
AppDebugLogLevel level = AppDebugLogLevel.info,
|
AppDebugLogLevel level = AppDebugLogLevel.info,
|
||||||
|
bool noNotify = false,
|
||||||
}) {
|
}) {
|
||||||
if (_enabled && _service != null) {
|
if (_enabled && _service != null) {
|
||||||
_service!.log(message, tag: tag, level: level);
|
_service!.log(message, tag: tag, level: level, noNotify: noNotify);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
enum ContactSortOption { lastSeen, recentMessages, name }
|
||||||
|
|
||||||
|
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:meshcore_open/models/discovery_contact.dart';
|
|
||||||
|
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
export 'contact_filter_types.dart';
|
||||||
|
|
||||||
bool matchesContactQuery(Contact contact, String query) {
|
bool matchesContactQuery(Contact contact, String query) {
|
||||||
final normalizedQuery = query.trim().toLowerCase();
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
if (normalizedQuery.isEmpty) return true;
|
if (normalizedQuery.isEmpty) return true;
|
||||||
@@ -16,7 +16,7 @@ bool matchesContactQuery(Contact contact, String query) {
|
|||||||
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
|
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) {
|
bool matchesDiscoveryContactQuery(Contact contact, String query) {
|
||||||
final normalizedQuery = query.trim().toLowerCase();
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
if (normalizedQuery.isEmpty) return true;
|
if (normalizedQuery.isEmpty) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../utils/contact_search.dart';
|
||||||
|
|
||||||
enum ContactSortOption { lastSeen, recentMessages, name }
|
class SortFilterMenuOption<T> {
|
||||||
|
final T value;
|
||||||
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
|
|
||||||
|
|
||||||
class SortFilterMenuOption {
|
|
||||||
final int value;
|
|
||||||
final String label;
|
final String label;
|
||||||
final bool? checked;
|
final bool? checked;
|
||||||
|
|
||||||
@@ -17,16 +14,16 @@ class SortFilterMenuOption {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class SortFilterMenuSection {
|
class SortFilterMenuSection<T> {
|
||||||
final String title;
|
final String title;
|
||||||
final List<SortFilterMenuOption> options;
|
final List<SortFilterMenuOption<T>> options;
|
||||||
|
|
||||||
const SortFilterMenuSection({required this.title, required this.options});
|
const SortFilterMenuSection({required this.title, required this.options});
|
||||||
}
|
}
|
||||||
|
|
||||||
class SortFilterMenu extends StatelessWidget {
|
class SortFilterMenu<T> extends StatelessWidget {
|
||||||
final List<SortFilterMenuSection> sections;
|
final List<SortFilterMenuSection<T>> sections;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<T> onSelected;
|
||||||
final String tooltip;
|
final String tooltip;
|
||||||
final Widget icon;
|
final Widget icon;
|
||||||
|
|
||||||
@@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopupMenuButton<int>(
|
return PopupMenuButton<T>(
|
||||||
icon: icon,
|
icon: icon,
|
||||||
tooltip: tooltip,
|
tooltip: tooltip,
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
@@ -53,11 +50,11 @@ class SortFilterMenu extends StatelessWidget {
|
|||||||
final visibleSections = sections
|
final visibleSections = sections
|
||||||
.where((section) => section.options.isNotEmpty)
|
.where((section) => section.options.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
final entries = <PopupMenuEntry<int>>[];
|
final entries = <PopupMenuEntry<T>>[];
|
||||||
for (int i = 0; i < visibleSections.length; i++) {
|
for (int i = 0; i < visibleSections.length; i++) {
|
||||||
final section = visibleSections[i];
|
final section = visibleSections[i];
|
||||||
entries.add(
|
entries.add(
|
||||||
PopupMenuItem<int>(
|
PopupMenuItem<T>(
|
||||||
enabled: false,
|
enabled: false,
|
||||||
child: Text(section.title, style: labelStyle),
|
child: Text(section.title, style: labelStyle),
|
||||||
),
|
),
|
||||||
@@ -65,14 +62,14 @@ class SortFilterMenu extends StatelessWidget {
|
|||||||
for (final option in section.options) {
|
for (final option in section.options) {
|
||||||
if (option.checked == null) {
|
if (option.checked == null) {
|
||||||
entries.add(
|
entries.add(
|
||||||
PopupMenuItem<int>(
|
PopupMenuItem<T>(
|
||||||
value: option.value,
|
value: option.value,
|
||||||
child: Text(option.label),
|
child: Text(option.label),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
entries.add(
|
entries.add(
|
||||||
CheckedPopupMenuItem<int>(
|
CheckedPopupMenuItem<T>(
|
||||||
value: option.value,
|
value: option.value,
|
||||||
checked: option.checked ?? false,
|
checked: option.checked ?? false,
|
||||||
child: Text(option.label),
|
child: Text(option.label),
|
||||||
@@ -90,16 +87,23 @@ class SortFilterMenu extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const int _actionSortRecentMessages = 1;
|
sealed class _ContactsFilterAction {
|
||||||
const int _actionSortName = 2;
|
const _ContactsFilterAction();
|
||||||
const int _actionSortLastSeen = 3;
|
}
|
||||||
const int _actionFilterAll = 4;
|
|
||||||
const int _actionFilterFavorites = 5;
|
class _SortAction extends _ContactsFilterAction {
|
||||||
const int _actionFilterUsers = 6;
|
final ContactSortOption option;
|
||||||
const int _actionFilterRepeaters = 7;
|
const _SortAction(this.option);
|
||||||
const int _actionFilterRooms = 8;
|
}
|
||||||
const int _actionToggleUnreadOnly = 9;
|
|
||||||
const int _actionNewGroup = 10;
|
class _TypeFilterAction extends _ContactsFilterAction {
|
||||||
|
final ContactTypeFilter filter;
|
||||||
|
const _TypeFilterAction(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ToggleUnreadAction extends _ContactsFilterAction {
|
||||||
|
const _ToggleUnreadAction();
|
||||||
|
}
|
||||||
|
|
||||||
class ContactsFilterMenu extends StatelessWidget {
|
class ContactsFilterMenu extends StatelessWidget {
|
||||||
final ContactSortOption sortOption;
|
final ContactSortOption sortOption;
|
||||||
@@ -108,7 +112,6 @@ class ContactsFilterMenu extends StatelessWidget {
|
|||||||
final ValueChanged<ContactSortOption> onSortChanged;
|
final ValueChanged<ContactSortOption> onSortChanged;
|
||||||
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
|
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
|
||||||
final ValueChanged<bool> onUnreadOnlyChanged;
|
final ValueChanged<bool> onUnreadOnlyChanged;
|
||||||
final VoidCallback onNewGroup;
|
|
||||||
|
|
||||||
const ContactsFilterMenu({
|
const ContactsFilterMenu({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -118,30 +121,29 @@ class ContactsFilterMenu extends StatelessWidget {
|
|||||||
required this.onSortChanged,
|
required this.onSortChanged,
|
||||||
required this.onTypeFilterChanged,
|
required this.onTypeFilterChanged,
|
||||||
required this.onUnreadOnlyChanged,
|
required this.onUnreadOnlyChanged,
|
||||||
required this.onNewGroup,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
return SortFilterMenu(
|
return SortFilterMenu<_ContactsFilterAction>(
|
||||||
tooltip: l10n.listFilter_tooltip,
|
tooltip: l10n.listFilter_tooltip,
|
||||||
sections: [
|
sections: [
|
||||||
SortFilterMenuSection(
|
SortFilterMenuSection(
|
||||||
title: l10n.listFilter_sortBy,
|
title: l10n.listFilter_sortBy,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortRecentMessages,
|
value: _SortAction(ContactSortOption.recentMessages),
|
||||||
label: l10n.listFilter_latestMessages,
|
label: l10n.listFilter_latestMessages,
|
||||||
checked: sortOption == ContactSortOption.recentMessages,
|
checked: sortOption == ContactSortOption.recentMessages,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortLastSeen,
|
value: _SortAction(ContactSortOption.lastSeen),
|
||||||
label: l10n.listFilter_heardRecently,
|
label: l10n.listFilter_heardRecently,
|
||||||
checked: sortOption == ContactSortOption.lastSeen,
|
checked: sortOption == ContactSortOption.lastSeen,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortName,
|
value: _SortAction(ContactSortOption.name),
|
||||||
label: l10n.listFilter_az,
|
label: l10n.listFilter_az,
|
||||||
checked: sortOption == ContactSortOption.name,
|
checked: sortOption == ContactSortOption.name,
|
||||||
),
|
),
|
||||||
@@ -151,80 +153,66 @@ class ContactsFilterMenu extends StatelessWidget {
|
|||||||
title: l10n.listFilter_filters,
|
title: l10n.listFilter_filters,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterAll,
|
value: _TypeFilterAction(ContactTypeFilter.all),
|
||||||
label: l10n.listFilter_all,
|
label: l10n.listFilter_all,
|
||||||
checked: typeFilter == ContactTypeFilter.all,
|
checked: typeFilter == ContactTypeFilter.all,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterFavorites,
|
value: _TypeFilterAction(ContactTypeFilter.favorites),
|
||||||
label: l10n.listFilter_favorites,
|
label: l10n.listFilter_favorites,
|
||||||
checked: typeFilter == ContactTypeFilter.favorites,
|
checked: typeFilter == ContactTypeFilter.favorites,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterUsers,
|
value: _TypeFilterAction(ContactTypeFilter.users),
|
||||||
label: l10n.listFilter_users,
|
label: l10n.listFilter_users,
|
||||||
checked: typeFilter == ContactTypeFilter.users,
|
checked: typeFilter == ContactTypeFilter.users,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterRepeaters,
|
value: _TypeFilterAction(ContactTypeFilter.repeaters),
|
||||||
label: l10n.listFilter_repeaters,
|
label: l10n.listFilter_repeaters,
|
||||||
checked: typeFilter == ContactTypeFilter.repeaters,
|
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterRooms,
|
value: _TypeFilterAction(ContactTypeFilter.rooms),
|
||||||
label: l10n.listFilter_roomServers,
|
label: l10n.listFilter_roomServers,
|
||||||
checked: typeFilter == ContactTypeFilter.rooms,
|
checked: typeFilter == ContactTypeFilter.rooms,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionToggleUnreadOnly,
|
value: const _ToggleUnreadAction(),
|
||||||
label: l10n.listFilter_unreadOnly,
|
label: l10n.listFilter_unreadOnly,
|
||||||
checked: showUnreadOnly,
|
checked: showUnreadOnly,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
|
||||||
value: _actionNewGroup,
|
|
||||||
label: l10n.listFilter_newGroup,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (action) {
|
onSelected: (action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case _actionSortRecentMessages:
|
case _SortAction(:final option):
|
||||||
onSortChanged(ContactSortOption.recentMessages);
|
onSortChanged(option);
|
||||||
break;
|
case _TypeFilterAction(:final filter):
|
||||||
case _actionSortName:
|
onTypeFilterChanged(filter);
|
||||||
onSortChanged(ContactSortOption.name);
|
case _ToggleUnreadAction():
|
||||||
break;
|
|
||||||
case _actionSortLastSeen:
|
|
||||||
onSortChanged(ContactSortOption.lastSeen);
|
|
||||||
break;
|
|
||||||
case _actionFilterAll:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.all);
|
|
||||||
break;
|
|
||||||
case _actionFilterUsers:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.users);
|
|
||||||
break;
|
|
||||||
case _actionFilterFavorites:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.favorites);
|
|
||||||
break;
|
|
||||||
case _actionFilterRepeaters:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.repeaters);
|
|
||||||
break;
|
|
||||||
case _actionFilterRooms:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.rooms);
|
|
||||||
break;
|
|
||||||
case _actionToggleUnreadOnly:
|
|
||||||
onUnreadOnlyChanged(!showUnreadOnly);
|
onUnreadOnlyChanged(!showUnreadOnly);
|
||||||
break;
|
|
||||||
case _actionNewGroup:
|
|
||||||
onNewGroup();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class _DiscoveryFilterAction {
|
||||||
|
const _DiscoveryFilterAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiscoverySortAction extends _DiscoveryFilterAction {
|
||||||
|
final ContactSortOption option;
|
||||||
|
const _DiscoverySortAction(this.option);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiscoveryTypeFilterAction extends _DiscoveryFilterAction {
|
||||||
|
final ContactTypeFilter filter;
|
||||||
|
const _DiscoveryTypeFilterAction(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
class DiscoveryContactsFilterMenu extends StatelessWidget {
|
class DiscoveryContactsFilterMenu extends StatelessWidget {
|
||||||
final ContactSortOption sortOption;
|
final ContactSortOption sortOption;
|
||||||
final ContactTypeFilter typeFilter;
|
final ContactTypeFilter typeFilter;
|
||||||
@@ -242,19 +230,19 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
return SortFilterMenu(
|
return SortFilterMenu<_DiscoveryFilterAction>(
|
||||||
tooltip: l10n.listFilter_tooltip,
|
tooltip: l10n.listFilter_tooltip,
|
||||||
sections: [
|
sections: [
|
||||||
SortFilterMenuSection(
|
SortFilterMenuSection(
|
||||||
title: l10n.listFilter_sortBy,
|
title: l10n.listFilter_sortBy,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortLastSeen,
|
value: _DiscoverySortAction(ContactSortOption.lastSeen),
|
||||||
label: l10n.listFilter_heardRecently,
|
label: l10n.listFilter_heardRecently,
|
||||||
checked: sortOption == ContactSortOption.lastSeen,
|
checked: sortOption == ContactSortOption.lastSeen,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionSortName,
|
value: _DiscoverySortAction(ContactSortOption.name),
|
||||||
label: l10n.listFilter_az,
|
label: l10n.listFilter_az,
|
||||||
checked: sortOption == ContactSortOption.name,
|
checked: sortOption == ContactSortOption.name,
|
||||||
),
|
),
|
||||||
@@ -264,22 +252,22 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
|||||||
title: l10n.listFilter_filters,
|
title: l10n.listFilter_filters,
|
||||||
options: [
|
options: [
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterAll,
|
value: _DiscoveryTypeFilterAction(ContactTypeFilter.all),
|
||||||
label: l10n.listFilter_all,
|
label: l10n.listFilter_all,
|
||||||
checked: typeFilter == ContactTypeFilter.all,
|
checked: typeFilter == ContactTypeFilter.all,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterUsers,
|
value: _DiscoveryTypeFilterAction(ContactTypeFilter.users),
|
||||||
label: l10n.listFilter_users,
|
label: l10n.listFilter_users,
|
||||||
checked: typeFilter == ContactTypeFilter.users,
|
checked: typeFilter == ContactTypeFilter.users,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterRepeaters,
|
value: _DiscoveryTypeFilterAction(ContactTypeFilter.repeaters),
|
||||||
label: l10n.listFilter_repeaters,
|
label: l10n.listFilter_repeaters,
|
||||||
checked: typeFilter == ContactTypeFilter.repeaters,
|
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||||
),
|
),
|
||||||
SortFilterMenuOption(
|
SortFilterMenuOption(
|
||||||
value: _actionFilterRooms,
|
value: _DiscoveryTypeFilterAction(ContactTypeFilter.rooms),
|
||||||
label: l10n.listFilter_roomServers,
|
label: l10n.listFilter_roomServers,
|
||||||
checked: typeFilter == ContactTypeFilter.rooms,
|
checked: typeFilter == ContactTypeFilter.rooms,
|
||||||
),
|
),
|
||||||
@@ -288,27 +276,10 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
onSelected: (action) {
|
onSelected: (action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case _actionSortName:
|
case _DiscoverySortAction(:final option):
|
||||||
onSortChanged(ContactSortOption.name);
|
onSortChanged(option);
|
||||||
break;
|
case _DiscoveryTypeFilterAction(:final filter):
|
||||||
case _actionSortLastSeen:
|
onTypeFilterChanged(filter);
|
||||||
onSortChanged(ContactSortOption.lastSeen);
|
|
||||||
break;
|
|
||||||
case _actionFilterAll:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.all);
|
|
||||||
break;
|
|
||||||
case _actionFilterUsers:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.users);
|
|
||||||
break;
|
|
||||||
case _actionFilterFavorites:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.favorites);
|
|
||||||
break;
|
|
||||||
case _actionFilterRepeaters:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.repeaters);
|
|
||||||
break;
|
|
||||||
case _actionFilterRooms:
|
|
||||||
onTypeFilterChanged(ContactTypeFilter.rooms);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,11 +33,22 @@ class _PathManagementDialog extends StatefulWidget {
|
|||||||
class _PathManagementDialogState extends State<_PathManagementDialog> {
|
class _PathManagementDialogState extends State<_PathManagementDialog> {
|
||||||
bool _showAllPaths = false;
|
bool _showAllPaths = false;
|
||||||
|
|
||||||
|
int _resolveContactIndex = -1;
|
||||||
|
|
||||||
Contact _resolveContact(MeshCoreConnector connector) {
|
Contact _resolveContact(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveContactIndex >= 0 &&
|
||||||
|
_resolveContactIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveContactIndex].publicKeyHex ==
|
||||||
|
widget.contact.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveContactIndex];
|
||||||
|
}
|
||||||
|
_resolveContactIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
||||||
orElse: () => widget.contact,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveContactIndex == -1) {
|
||||||
|
return widget.contact;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveContactIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatRelativeTime(BuildContext context, DateTime time) {
|
String _formatRelativeTime(BuildContext context, DateTime time) {
|
||||||
@@ -78,7 +89,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||||||
builder: (context) => PathTraceMapScreen(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: context.l10n.contacts_repeaterPathTrace,
|
title: context.l10n.contacts_repeaterPathTrace,
|
||||||
path: Uint8List.fromList(pathBytes),
|
path: Uint8List.fromList(pathBytes),
|
||||||
flipPathRound: true,
|
flipPathAround: true,
|
||||||
targetContact: widget.contact,
|
targetContact: widget.contact,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -107,7 +118,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final pathForInput = currentContact.pathIdList;
|
final pathForInput = currentContact.pathIdList;
|
||||||
final availableContacts = connector.contacts
|
final availableContacts = connector.allContacts
|
||||||
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
|
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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';
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||||||
|
|
||||||
void _filterValidContacts() {
|
void _filterValidContacts() {
|
||||||
_validContacts = widget.availableContacts
|
_validContacts = widget.availableContacts
|
||||||
.where((c) => c.type == 2 || c.type == 3)
|
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,21 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
|
|
||||||
bool _isLoggingIn = false;
|
bool _isLoggingIn = false;
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
|
|||||||
@@ -64,11 +64,22 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
|
|
||||||
bool _isLoggingIn = false;
|
bool _isLoggingIn = false;
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.room.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.room.publicKeyHex,
|
(c) => c.publicKeyHex == widget.room.publicKeyHex,
|
||||||
orElse: () => widget.room,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.room;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
repeater.snr,
|
repeater.snr,
|
||||||
widget.connector.currentSf,
|
widget.connector.currentSf,
|
||||||
);
|
);
|
||||||
|
final allContacts = widget.connector.allContacts;
|
||||||
final name = widget.connector.contacts
|
final name = allContacts
|
||||||
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
|
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
|
||||||
.map((c) => c.name)
|
.map((c) => c.name)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
|
|||||||
@@ -14,9 +14,11 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.usb</key>
|
<key>com.apple.security.device.usb</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
|
||||||
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
||||||
<array>
|
<array>
|
||||||
<string>/dev/</string>
|
<string>/dev/cu.</string>
|
||||||
|
<string>/dev/tty.</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.usb</key>
|
<key>com.apple.security.device.usb</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
|
||||||
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
||||||
<array>
|
<array>
|
||||||
<string>/dev/</string>
|
<string>/dev/cu.</string>
|
||||||
|
<string>/dev/tty.</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
+4
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 6.0.0+7
|
version: 7.0.0+8
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -38,6 +38,7 @@ dependencies:
|
|||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_blue_plus: ^2.1.0
|
flutter_blue_plus: ^2.1.0
|
||||||
|
# TODO: Switch to official flserial repo once changes are upstreamed
|
||||||
flserial:
|
flserial:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/MeshEnvy/flserial.git
|
url: https://github.com/MeshEnvy/flserial.git
|
||||||
@@ -68,6 +69,8 @@ dependencies:
|
|||||||
material_symbols_icons: ^4.2906.0
|
material_symbols_icons: ^4.2906.0
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
flutter_svg: ^2.0.10+1
|
flutter_svg: ^2.0.10+1
|
||||||
|
ml_algo: ^16.0.0
|
||||||
|
ml_dataframe: ^1.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('shouldIgnoreLateTcpConnectError', () {
|
||||||
|
test('returns true for manual cancel during disconnecting state', () {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: true,
|
||||||
|
state: MeshCoreConnectionState.disconnecting,
|
||||||
|
activeTransport: MeshCoreTransportType.bluetooth,
|
||||||
|
tcpManagerConnected: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'returns true for manual cancel after reaching disconnected state',
|
||||||
|
() {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: true,
|
||||||
|
state: MeshCoreConnectionState.disconnected,
|
||||||
|
activeTransport: MeshCoreTransportType.bluetooth,
|
||||||
|
tcpManagerConnected: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('returns false when not a manual disconnect', () {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: false,
|
||||||
|
state: MeshCoreConnectionState.disconnecting,
|
||||||
|
activeTransport: MeshCoreTransportType.bluetooth,
|
||||||
|
tcpManagerConnected: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for connected state handshake failures', () {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: true,
|
||||||
|
state: MeshCoreConnectionState.connected,
|
||||||
|
activeTransport: MeshCoreTransportType.tcp,
|
||||||
|
tcpManagerConnected: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when TCP is still active while disconnecting', () {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: true,
|
||||||
|
state: MeshCoreConnectionState.disconnecting,
|
||||||
|
activeTransport: MeshCoreTransportType.tcp,
|
||||||
|
tcpManagerConnected: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('shouldResetStateAfterTcpConnectAbort', () {
|
||||||
|
test('returns true when TCP connect is still in connecting state', () {
|
||||||
|
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||||
|
state: MeshCoreConnectionState.connecting,
|
||||||
|
activeTransport: MeshCoreTransportType.tcp,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when state is already disconnected', () {
|
||||||
|
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||||
|
state: MeshCoreConnectionState.disconnected,
|
||||||
|
activeTransport: MeshCoreTransportType.tcp,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when transport switched away from TCP', () {
|
||||||
|
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||||
|
state: MeshCoreConnectionState.connecting,
|
||||||
|
activeTransport: MeshCoreTransportType.bluetooth,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
|
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_open/screens/scanner_screen.dart';
|
||||||
|
import 'package:meshcore_open/screens/tcp_screen.dart';
|
||||||
|
import 'package:meshcore_open/services/app_settings_service.dart';
|
||||||
|
|
||||||
|
class _FakeMeshCoreConnector extends MeshCoreConnector {
|
||||||
|
_FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
|
||||||
|
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
|
||||||
|
String? initialEndpoint;
|
||||||
|
int connectTcpCalls = 0;
|
||||||
|
String? lastHost;
|
||||||
|
int? lastPort;
|
||||||
|
|
||||||
|
@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({
|
||||||
|
required MeshCoreConnector connector,
|
||||||
|
required Widget child,
|
||||||
|
Locale? locale,
|
||||||
|
}) {
|
||||||
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<MeshCoreConnector>.value(value: connector),
|
||||||
|
ChangeNotifierProvider<AppSettingsService>(
|
||||||
|
create: (_) => AppSettingsService(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('TcpScreen uses localized TCP copy', (tester) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(
|
||||||
|
connector: connector,
|
||||||
|
child: const TcpScreen(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(TcpScreen));
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
expect(find.text(l10n.tcpScreenTitle), findsOneWidget);
|
||||||
|
expect(find.text(l10n.tcpHostLabel), findsOneWidget);
|
||||||
|
expect(find.text(l10n.tcpPortLabel), findsOneWidget);
|
||||||
|
expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TcpScreen validation errors are localized', (tester) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(
|
||||||
|
connector: connector,
|
||||||
|
child: const TcpScreen(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(TcpScreen));
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, '');
|
||||||
|
await tester.tap(find.byKey(const Key('tcp_connect_button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget);
|
||||||
|
expect(connector.connectTcpCalls, 0);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, '192.168.1.50');
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), '99999');
|
||||||
|
await tester.tap(find.byKey(const Key('tcp_connect_button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(connector.connectTcpCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TCP Bluetooth action returns to existing scanner route', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TcpScreen), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(TcpScreen), findsNothing);
|
||||||
|
expect(find.byType(ScannerScreen), findsOneWidget);
|
||||||
|
final navigatorState = tester.state<NavigatorState>(find.byType(Navigator));
|
||||||
|
expect(navigatorState.canPop(), isFalse);
|
||||||
|
|
||||||
|
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
|
||||||
|
// Drain that debounce timer before test teardown.
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TcpScreen disables connect button while connector is scanning', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector()
|
||||||
|
..initialState = MeshCoreConnectionState.scanning;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(
|
||||||
|
connector: connector,
|
||||||
|
child: const TcpScreen(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final button = tester.widget<ButtonStyleButton>(
|
||||||
|
find.byKey(const Key('tcp_connect_button')),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
expect(connector.connectTcpCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TcpScreen narrow width long status text does not overflow', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final connector = _FakeMeshCoreConnector()
|
||||||
|
..initialState = MeshCoreConnectionState.connected
|
||||||
|
..initialTransport = MeshCoreTransportType.tcp
|
||||||
|
..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(
|
||||||
|
connector: connector,
|
||||||
|
child: const TcpScreen(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(TcpScreen));
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
expect(
|
||||||
|
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user