Compare commits

..

17 Commits

Author SHA1 Message Date
Winston Lowe 5ad9263cc4 feat: Refactor repeater resolution logic across multiple screens 2026-03-19 16:17:25 -07:00
Winston Lowe 3f780ac667 feat: Enhance privacy settings and telemetry
- Implemented telemetry options for contacts, allowing users to enable or disable telemetry data sharing.
- Introduced a clear chat option in the chat interface for better message management.
- Updated the telemetry screen to handle telemetry data for contacts, including battery level.
- Refactored contact settings to include telemetry options and improved UI for better user experience.
2026-03-19 15:56:52 -07:00
zjs81 53caec3e14 Merge pull request #301 from just-stuff-tm/fix/tcp-flow-test-missing-provider
fix: provide AppSettingsService in tcp_flow_test
2026-03-16 16:10:29 -07:00
just_stuff_tm 3c440ca3d4 Merge branch 'zjs81:main' into fix/tcp-flow-test-missing-provider 2026-03-15 21:09:02 -04:00
zjs81 8797d8ffde Merge pull request #302 from stphnrdmr/doc/platform-support
Add more explicit platform support table
2026-03-15 15:21:22 -07:00
Stephan Rodemeier faba120823 Add more explicit platform support table
The platform support was a bit vague, this adds a table to better convey
the differences.
2026-03-15 23:01:38 +01:00
just-stuff-tm be690c8194 fix: provide AppSettingsService in tcp_flow_test
TcpScreen.initState reads AppSettingsService from context
to pre-fill host/port fields, but the test helper only
provided MeshCoreConnector. Switch to MultiProvider so
AppSettingsService is also in the widget tree.
2026-03-15 16:48:40 -04:00
zjs81 64d75dde45 chore: update version to 7.0.0+8 in pubspec.yaml 2026-03-14 18:46:29 -07:00
zjs81 9199aab7f7 Merge pull request #297 from zjs81/dev-improments
Improvements to path tracing and location handling
2026-03-14 18:42:58 -07:00
zjs81 64698e0be6 Merge pull request #295 from ericszimmermann/ez_group_dropdown3
squashed PR for Dropdown Group Menu
2026-03-14 18:05:22 -07:00
zjs81 3dd9037be3 Merge remote-tracking branch 'origin/main' into ez_group_dropdown3
# Conflicts:
#	lib/main.dart
2026-03-14 18:02:31 -07:00
zjs81 566e3aadf8 fix: migrate filter menus to type-safe generics and harden popup dismissal
- Move ContactSortOption/ContactTypeFilter enums to dedicated
  contact_filter_types.dart (re-exported from contact_search.dart)
- Migrate ContactsFilterMenu and DiscoveryContactsFilterMenu to use
  sealed class action types with SortFilterMenu<T> generics, replacing
  int action constants and switch statements
- Guard _closeDropdownAndRun with ModalRoute.isCurrent check to prevent
  accidental dismissal of parent routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 17:59:48 -07:00
zjs81 054a84031e Merge pull request #296 from zjs81/feature/ml-timeout-prediction
feat: add ML-based adaptive timeout prediction using LinearRegressor
2026-03-14 17:39:22 -07:00
zjs81 fffcff3b74 fix: cancel persist timer on dispose to prevent post-dispose writes 2026-03-14 17:39:01 -07:00
zjs81 b336aedbc5 fix: address PR #296 code review feedback
- Clamp ML predictions between physics floor (raw airtime) and ceiling
  (worst-case formula) so model can never produce unsafe timeouts
- Replace hourOfDay feature with secondsSinceLastRx for network activity
- Remove unused _ContactStats.stdDev and dead model persistence code
- Debounce observation writes (2s) instead of writing on every delivery
- Skip recording observations when pathLength is null to avoid corrupting
  training data
- Add comment explaining global (not per-contact) RX time tracking
- Remove notifyListeners from retrain to avoid unnecessary widget rebuilds
- Run dart format
2026-03-14 17:32:08 -07:00
zjs81 2ee2358ecc feat: add ML-based adaptive timeout prediction using LinearRegressor
Train a linear regression model on actual message delivery times to
predict tighter timeouts, replacing worst-case physics estimates.
Features: path length, message bytes, seconds since last RX, flood mode.
Global model with per-contact blending after 10+ observations per contact.
Falls back to existing physics formula when model has insufficient data.
2026-03-14 16:56:11 -07:00
ericz 86e9b7fe01 squashed commit of ez_group_dropdown 2026-03-15 00:34:09 +01:00
63 changed files with 4072 additions and 694 deletions
+12 -5
View File
@@ -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
+150 -25
View File
@@ -19,6 +19,7 @@ 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 'meshcore_connector_tcp.dart';
@@ -166,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;
@@ -319,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;
@@ -672,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;
@@ -679,6 +690,7 @@ 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); _tcpConnector.setDebugLogService(_appDebugLogService);
@@ -693,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,
);
},
); );
} }
@@ -1791,13 +1818,36 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
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
? (latestContact.flags | contactFlagFavorite) ? (isFavorite
: (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(
@@ -2334,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) {
@@ -2509,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);
@@ -2885,38 +2961,68 @@ 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));
} }
} }
/// 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}) { void _handleContact(Uint8List frame, {bool isContact = true}) {
final contact = Contact.fromFrame(frame); final contact = Contact.fromFrame(frame);
if (contact != null) { if (contact != null) {
@@ -4922,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;
+23 -1
View File
@@ -210,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;
@@ -272,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)
@@ -352,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;
@@ -937,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();
}
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "Връзката TCP изтекла.", "tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}", "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване", "map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение" "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": "Режим на телеметрията е обновен"
} }
+33 -1
View File
@@ -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": {
@@ -1916,5 +1917,36 @@
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.", "tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}", "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen", "map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen" "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}"
} }
+32
View File
@@ -166,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",
@@ -416,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": {
@@ -454,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",
+33 -1
View File
@@ -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": {
@@ -1916,5 +1917,36 @@
"tcpErrorTimedOut": "La conexión TCP ha caducado.", "tcpErrorTimedOut": "La conexión TCP ha caducado.",
"tcpConnectionFailed": "Error en la conexión TCP: {error}", "tcpConnectionFailed": "Error en la conexión TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento", "map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
"map_setAsMyLocation": "Establecer mi ubicación" "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}"
} }
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "La connexion TCP a expiré.", "tcpErrorTimedOut": "La connexion TCP a expiré.",
"tcpConnectionFailed": "Échec de la connexion TCP : {error}", "tcpConnectionFailed": "Échec de la connexion TCP : {error}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte", "map_showDiscoveryContacts": "Afficher les contacts de découverte",
"map_setAsMyLocation": "Définir comme ma localisation" "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"
} }
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "La connessione TCP è scaduta.", "tcpErrorTimedOut": "La connessione TCP è scaduta.",
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}", "tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery", "map_showDiscoveryContacts": "Mostra Contatti di Discovery",
"map_setAsMyLocation": "Imposta come la mia posizione" "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}"
} }
+150
View File
@@ -826,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:
@@ -1714,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:
@@ -1774,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:
+85
View File
@@ -398,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 => 'Действия';
@@ -902,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\" вече съществува.';
@@ -941,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 => 'Канали';
+82
View File
@@ -398,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';
@@ -902,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.';
@@ -941,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';
+79
View File
@@ -392,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';
@@ -889,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';
@@ -927,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';
+85
View File
@@ -396,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';
@@ -901,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';
@@ -940,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';
+85
View File
@@ -400,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';
@@ -905,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à.';
@@ -944,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';
+85
View File
@@ -398,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';
@@ -901,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à.';
@@ -940,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';
+81
View File
@@ -395,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';
@@ -895,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.';
@@ -934,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';
+85
View File
@@ -401,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';
@@ -904,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';
@@ -943,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';
+84
View File
@@ -398,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';
@@ -903,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';
@@ -942,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';
+84
View File
@@ -398,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 => 'Действия';
@@ -902,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\" уже существует';
@@ -941,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 => 'Каналы';
+81
View File
@@ -395,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';
@@ -894,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';
@@ -935,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';
+82
View File
@@ -393,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';
@@ -892,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';
@@ -931,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';
+80
View File
@@ -392,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';
@@ -888,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.';
@@ -927,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';
+83
View File
@@ -395,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 => 'Дії';
@@ -898,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» вже існує.';
@@ -937,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 => 'Канали';
+77
View File
@@ -374,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 => '操作';
@@ -845,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\" 的群聊已存在';
@@ -883,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 => '频道';
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.", "tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}", "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven", "map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie" "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}"
} }
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.", "tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
"tcpConnectionFailed": "Błąd połączenia TCP: {error}", "tcpConnectionFailed": "Błąd połączenia TCP: {error}",
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania", "map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
"map_setAsMyLocation": "Ustaw jako moje lokalizację" "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}"
} }
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "A conexão TCP expirou.", "tcpErrorTimedOut": "A conexão TCP expirou.",
"tcpConnectionFailed": "Falha na conexão TCP: {error}", "tcpConnectionFailed": "Falha na conexão TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta", "map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
"map_setAsMyLocation": "Defina minha localização" "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}"
} }
+33 -1
View File
@@ -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": "Нет контактов, соответствующих фильтру",
@@ -1128,5 +1129,36 @@
"tcpErrorTimedOut": "Соединение TCP не удалось установить.", "tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}", "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery", "map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение" "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}"
} }
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.", "tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}", "tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov", "map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu" "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}"
} }
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.", "tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}", "tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov", "map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo" "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"
} }
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.", "tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}", "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter", "map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats" "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}"
} }
+33 -1
View File
@@ -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": {
@@ -1888,5 +1889,36 @@
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}", "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття", "map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження" "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}"
} }
+33 -1
View File
@@ -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": {
@@ -1893,5 +1894,36 @@
"tcpErrorTimedOut": "TCP 连接超时。", "tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}", "tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人", "map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置" "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": "遥测模式已更新"
} }
+15
View File
@@ -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) {
+3
View File
@@ -202,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;
} }
+43
View File
@@ -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),
);
}
}
+27
View File
@@ -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,
+44 -55
View File
@@ -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,9 +42,7 @@ 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
@@ -56,6 +53,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@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();
@@ -110,6 +110,7 @@ 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; channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
@@ -205,6 +206,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final filteredChannels = _filterAndSortChannels( final filteredChannels = _filterAndSortChannels(
channels, channels,
connector, connector,
viewState,
); );
return Column( return Column(
@@ -219,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(
@@ -246,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);
}, },
); );
}, },
@@ -283,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,
@@ -584,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;
}
});
}, },
); );
} }
@@ -644,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) {
@@ -657,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:
+223 -55
View File
@@ -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;
@@ -244,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),
),
],
),
),
],
);
},
), ),
], ],
), ),
@@ -874,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(
@@ -931,59 +1011,127 @@ 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); content: SingleChildScrollView(
final smazEnabled = connector.isContactSmazEnabled( child: Column(
contact.publicKeyHex, mainAxisSize: MainAxisSize.min,
); crossAxisAlignment: CrossAxisAlignment.start,
children: [
return AlertDialog( _buildInfoRow(context.l10n.chat_type, contact.typeLabel),
title: Text(contact.name), _buildInfoRow(context.l10n.chat_path, contact.pathLabel),
content: SingleChildScrollView( _buildInfoRow(
child: Column( context.l10n.contact_lastSeen,
mainAxisSize: MainAxisSize.min, _formatContactLastMessage(contact.lastMessageAt),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
if (contact.hasLocation)
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow(
context.l10n.chat_publicKey,
'${contact.publicKeyHex.substring(0, 16)}...',
),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(
contact.publicKeyHex,
value,
);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
), ),
if (contact.hasLocation)
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow(context.l10n.chat_publicKey, contact.publicKeyHex),
], ],
); ),
}, ),
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(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(
contact.publicKeyHex,
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);
},
),
],
),
),
actions: [
TextButton(
onPressed: () {
connector.setContactFlags(
contact,
teleBase: teleBaseEnabled,
teleLoc: teleLocEnabled,
teleEnv: teleEnvEnabled,
);
Navigator.pop(context);
},
child: Text(context.l10n.common_close),
),
],
),
), ),
); );
} }
@@ -998,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);
+458 -280
View File
@@ -13,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';
@@ -48,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 = {};
@@ -63,6 +62,9 @@ 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(); _clearAdvertNotifications();
@@ -74,26 +76,84 @@ class _ContactsScreenState extends State<ContactsScreen>
NotificationService().clearAdvertNotifications(contactIds); 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
void dispose() { void dispose() {
_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
@@ -383,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) {
if (selectedGroupName == contactsAllGroupsValue) return null;
for (final group in _groups) {
if (group.name == selectedGroupName) return group;
}
return null;
}
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( return ContactsFilterMenu(
sortOption: _sortOption, sortOption: viewState.contactsSortOption,
typeFilter: _typeFilter, typeFilter: viewState.contactsTypeFilter,
showUnreadOnly: _showUnreadOnly, showUnreadOnly: viewState.contactsShowUnreadOnly,
onSortChanged: (value) { onSortChanged: (value) {
setState(() { viewState.setContactsSortOption(value);
_sortOption = 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 &&
@@ -429,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, children: [
decoration: InputDecoration( Expanded(
hintText: hintText, child: _buildGroupButton(
prefixIcon: const Icon(Icons.search), context,
suffixIcon: Row( connector,
mainAxisSize: MainAxisSize.min, viewState,
children: [ contacts,
if (_searchQuery.isNotEmpty) sortedGroups,
IconButton( ),
icon: const Icon(Icons.clear), ),
onPressed: () { const SizedBox(width: 8),
_searchController.clear(); AnimatedContainer(
setState(() { duration: const Duration(milliseconds: 220),
_searchQuery = ''; curve: Curves.easeOutCubic,
}); width: viewState.contactsSearchExpanded
}, ? searchExpandedWidth
: searchCollapsedWidth,
height: 48,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
), ),
_buildFilterButton(context, connector), 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(
horizontal: 12,
vertical: 10,
),
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 300),
() {
if (!mounted) return;
context
.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),
),
],
),
),
), ),
border: OutlineInputBorder( ],
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
if (!mounted) return;
setState(() {
_searchQuery = value.toLowerCase();
});
});
},
), ),
), ),
Expanded( Expanded(
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty child: filteredAndSorted.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -522,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]),
@@ -533,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,
); );
@@ -561,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!);
@@ -618,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)),
@@ -657,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:
@@ -679,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) {
@@ -807,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(
@@ -874,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();
}, },
@@ -892,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 ?? []};
@@ -918,64 +1070,70 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
content: SizedBox( content: SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: Column( child: ConstrainedBox(
mainAxisSize: MainAxisSize.min, constraints: BoxConstraints(
children: [ maxHeight: MediaQuery.of(context).size.height * 0.8,
TextField( ),
controller: nameController, child: Column(
decoration: InputDecoration( mainAxisSize: MainAxisSize.min,
labelText: context.l10n.contacts_groupName, children: [
border: const OutlineInputBorder(), TextField(
controller: nameController,
decoration: InputDecoration(
labelText: context.l10n.contacts_groupName,
border: const OutlineInputBorder(),
),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), TextField(
TextField( decoration: InputDecoration(
decoration: InputDecoration( hintText: context.l10n.contacts_filterContacts,
hintText: context.l10n.contacts_filterContacts, prefixIcon: const Icon(Icons.search),
prefixIcon: const Icon(Icons.search), border: const OutlineInputBorder(),
border: const OutlineInputBorder(), isDense: true,
isDense: true, ),
onChanged: (value) {
setDialogState(() {
filterQuery = value.toLowerCase();
});
},
), ),
onChanged: (value) { const SizedBox(height: 12),
setDialogState(() { Expanded(
filterQuery = value.toLowerCase(); child: filteredContacts.isEmpty
}); ? Center(
}, child: Text(
), context.l10n.contacts_noContactsMatchFilter,
const SizedBox(height: 12), ),
SizedBox( )
height: 240, : ListView.builder(
child: filteredContacts.isEmpty itemCount: filteredContacts.length,
? Center( itemBuilder: (context, index) {
child: Text( final contact = filteredContacts[index];
context.l10n.contacts_noContactsMatchFilter, final isSelected = selectedKeys.contains(
contact.publicKeyHex,
);
return CheckboxListTile(
value: isSelected,
title: Text(contact.name),
subtitle: Text(contact.typeLabel),
onChanged: (value) {
setDialogState(() {
if (value == true) {
selectedKeys.add(contact.publicKeyHex);
} else {
selectedKeys.remove(
contact.publicKeyHex,
);
}
});
},
);
},
), ),
) ),
: ListView.builder( ],
itemCount: filteredContacts.length, ),
itemBuilder: (context, index) {
final contact = filteredContacts[index];
final isSelected = selectedKeys.contains(
contact.publicKeyHex,
);
return CheckboxListTile(
value: isSelected,
title: Text(contact.name),
subtitle: Text(contact.typeLabel),
onChanged: (value) {
setDialogState(() {
if (value == true) {
selectedKeys.add(contact.publicKeyHex);
} else {
selectedKeys.remove(contact.publicKeyHex);
}
});
},
);
},
),
),
],
), ),
), ),
actions: [ actions: [
@@ -994,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();
@@ -1009,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(
@@ -1026,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) {
@@ -1178,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(
+18 -7
View File
@@ -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();
@@ -163,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;
+13 -2
View File
@@ -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) {
+1 -2
View File
@@ -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),
), ),
); );
}, },
+13 -2
View File
@@ -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) {
+13 -2
View File
@@ -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) {
+134 -44
View File
@@ -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;
+95 -73
View File
@@ -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);
try {
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);
});
}
if (frame[0] == respCodeSent) { // Check if it's a binary response
_tagData = frame.sublist(2, 6); if (cmd == pushCodeBinaryResponse) {
} if (!mounted) return;
reader.skipBytes(1); // Skip the reserved byte
if (reader.readUInt32LE() != _tagData) return;
_handleTelemetryResponse(reader.readRemainingBytes());
}
// Check if it's a binary response // Check if it's a telemetry response (for chat contacts)
if (frame[0] == pushCodeBinaryResponse && if (cmd == pushCodeTelemetryResponse) {
listEquals(frame.sublist(2, 6), _tagData)) { reader.skipBytes(1); // Skip the reserved byte
if (!mounted) return; final pubkey = reader.readBytes(6);
_handleStatusResponse(frame.sublist(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) {
payload: Uint8List.fromList([reqTypeGetTelemetry]), frame = buildSendBinaryReq(
); widget.contact.publicKey,
await connector.sendFrame(frame); payload: Uint8List.fromList([reqTypeGetTelemetry]),
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
var messageBytes = frame.length >= _statusResponseBytes
? frame.length
: _statusResponseBytes;
if (messageBytes < maxFrameSize) {
messageBytes = maxFrameSize;
}
final timeoutMs = connector.calculateTimeout(
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); } else {
}); frame = buildSendTelemetryReq(widget.contact.publicKey);
}
await connector.sendFrame(frame);
} 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,
); );
} }
+1 -1
View File
@@ -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();
+45 -17
View File
@@ -58,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();
@@ -73,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;
@@ -91,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:
@@ -423,25 +433,33 @@ 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 pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
int actualTimeout = timeoutMs; int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) { if (_calculateTimeoutCallback != null) {
int pathLengthValue; final calculated = _calculateTimeoutCallback!(
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(
pathLengthValue, pathLengthValue,
message.text.length, message.text.length,
contactKey: contact.publicKeyHex,
); );
debugPrint( // calculateTimeout tries ML first, falls back to physics.
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue', // 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(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
}
} }
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
@@ -738,6 +756,16 @@ 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); _onMessageResolved(matchedMessageId, contact.publicKeyHex);
} }
+31
View File
@@ -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,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());
}
}
}
+154
View File
@@ -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),
);
}
}
+3
View File
@@ -0,0 +1,3 @@
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
+2
View File
@@ -1,5 +1,7 @@
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;
+70 -99
View File
@@ -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;
} }
}, },
); );
+13 -2
View File
@@ -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) {
+12 -2
View File
@@ -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 {
+13 -2
View File
@@ -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 {
+3 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 6.0.0+7 version: 7.0.0+8
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -69,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:
+8 -2
View File
@@ -6,6 +6,7 @@ import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart'; import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/screens/scanner_screen.dart'; import 'package:meshcore_open/screens/scanner_screen.dart';
import 'package:meshcore_open/screens/tcp_screen.dart'; import 'package:meshcore_open/screens/tcp_screen.dart';
import 'package:meshcore_open/services/app_settings_service.dart';
class _FakeMeshCoreConnector extends MeshCoreConnector { class _FakeMeshCoreConnector extends MeshCoreConnector {
_FakeMeshCoreConnector(); _FakeMeshCoreConnector();
@@ -44,8 +45,13 @@ Widget _buildTestApp({
required Widget child, required Widget child,
Locale? locale, Locale? locale,
}) { }) {
return ChangeNotifierProvider<MeshCoreConnector>.value( return MultiProvider(
value: connector, providers: [
ChangeNotifierProvider<MeshCoreConnector>.value(value: connector),
ChangeNotifierProvider<AppSettingsService>(
create: (_) => AppSettingsService(),
),
],
child: MaterialApp( child: MaterialApp(
locale: locale, locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
void main() {
test('LinearRegressor basic sanity check', () {
// Simple: y = 2x + 100
final data = DataFrame(
[
[1.0, 102.0],
[2.0, 104.0],
[3.0, 106.0],
[4.0, 108.0],
[5.0, 110.0],
[10.0, 120.0],
[20.0, 140.0],
[50.0, 200.0],
[0.0, 100.0],
[100.0, 300.0],
],
headerExists: false,
header: ['x', 'y'],
);
debugPrint('Training data columns: ${data.header}');
debugPrint('Training data rows: ${data.rows.length}');
final model = LinearRegressor(data, 'y');
final testDf = DataFrame(
[
[25.0],
],
headerExists: false,
header: ['x'],
);
final prediction = model.predict(testDf);
final value = prediction.rows.first.first;
debugPrint('Predict x=25 → y=$value (expected ~150)');
expect((value as num).toDouble(), closeTo(150, 5));
});
test('LinearRegressor multi-feature with constant column produces zeros', () {
// isFlood=0 for all rows zero-variance column singular matrix
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 14.0, 0.0, 2200.0],
[2.0, 50.0, 14.0, 0.0, 5000.0],
[4.0, 50.0, 14.0, 0.0, 9500.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
final testDf = DataFrame(
[
[2.0, 50.0, 14.0, 0.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)',
);
});
test('LinearRegressor 2-feature works correctly', () {
// Just pathLength + messageBytes deliveryMs
final data = DataFrame(
[
[0.0, 50.0, 1900.0],
[0.0, 80.0, 2200.0],
[2.0, 50.0, 5000.0],
[2.0, 80.0, 5500.0],
[4.0, 50.0, 9500.0],
[4.0, 80.0, 10000.0],
[0.0, 30.0, 1800.0],
[2.0, 30.0, 4800.0],
[4.0, 30.0, 9000.0],
[0.0, 60.0, 2000.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'deliveryMs'],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final hops in [0.0, 2.0, 4.0]) {
final testDf = DataFrame(
[
[hops, 50.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint('2-feature: hops=$hops${(pred as num).round()}ms');
}
});
test('LinearRegressor multi-feature with variance in all columns', () {
// Mix flood and direct so isFlood has variance
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 10.0, 0.0, 2200.0],
[2.0, 50.0, 16.0, 0.0, 5000.0],
[2.0, 80.0, 20.0, 0.0, 5500.0],
[4.0, 50.0, 8.0, 0.0, 9500.0],
[4.0, 80.0, 12.0, 0.0, 10000.0],
[-1.0, 40.0, 14.0, 1.0, 5000.0],
[-1.0, 60.0, 18.0, 1.0, 6500.0],
[-1.0, 30.0, 10.0, 1.0, 4000.0],
[-1.0, 80.0, 22.0, 1.0, 7000.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final tc in [
[0.0, 50.0, 14.0, 0.0],
[2.0, 50.0, 14.0, 0.0],
[4.0, 50.0, 14.0, 0.0],
[-1.0, 50.0, 14.0, 1.0],
]) {
final testDf = DataFrame(
[tc],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'4-feature: hops=${tc[0]} flood=${tc[3]}${(pred as num).round()}ms',
);
}
});
}
@@ -0,0 +1,164 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/delivery_observation.dart';
import 'package:meshcore_open/services/timeout_prediction_service.dart';
void main() {
late TimeoutPredictionService service;
setUp(() {
service = TimeoutPredictionService.noStorage();
});
test('trains on sample data and predicts sensible timeouts', () {
// Simulate realistic delivery data:
// Direct 0-hop messages: ~1500-2500ms
// 2-hop messages: ~4000-6000ms
// 4-hop messages: ~8000-12000ms
// Flood messages: ~3000-8000ms
final sampleData = [
// 0-hop direct
_obs(pathLength: 0, messageBytes: 20, deliveryMs: 1800),
_obs(pathLength: 0, messageBytes: 50, deliveryMs: 2100),
_obs(pathLength: 0, messageBytes: 80, deliveryMs: 2400),
_obs(pathLength: 0, messageBytes: 30, deliveryMs: 1925),
// 2-hop direct
_obs(pathLength: 2, messageBytes: 40, deliveryMs: 4500),
_obs(pathLength: 2, messageBytes: 60, deliveryMs: 5200),
_obs(pathLength: 2, messageBytes: 25, deliveryMs: 4100),
// 4-hop direct
_obs(pathLength: 4, messageBytes: 50, deliveryMs: 9800),
_obs(pathLength: 4, messageBytes: 30, deliveryMs: 8500),
_obs(pathLength: 4, messageBytes: 70, deliveryMs: 10570),
// Flood
_obs(pathLength: -1, messageBytes: 40, deliveryMs: 5000),
_obs(pathLength: -1, messageBytes: 60, deliveryMs: 6500),
];
// Feed all observations
for (final obs in sampleData) {
service.recordObservation(
contactKey: obs.contactKey,
pathLength: obs.pathLength,
messageBytes: obs.messageBytes,
tripTimeMs: obs.deliveryMs,
);
}
expect(service.hasModel, isTrue);
expect(service.observationCount, equals(12));
// Predict for different scenarios
final direct0 = service.predictTimeout(pathLength: 0, messageBytes: 50);
final direct2 = service.predictTimeout(pathLength: 2, messageBytes: 50);
final direct4 = service.predictTimeout(pathLength: 4, messageBytes: 50);
final flood = service.predictTimeout(pathLength: -1, messageBytes: 50);
// All should return non-null (model is trained)
expect(direct0, isNotNull);
expect(direct2, isNotNull);
expect(direct4, isNotNull);
expect(flood, isNotNull);
// More hops should predict longer timeouts
expect(direct4!, greaterThan(direct2!));
expect(direct2, greaterThan(direct0!));
// All should be positive
expect(direct0, greaterThan(0));
expect(direct4, greaterThan(0));
// Print predictions for visibility
debugPrint('Predictions (with 1.5x safety margin):');
debugPrint(' 0-hop direct: ${direct0}ms');
debugPrint(' 2-hop direct: ${direct2}ms');
debugPrint(' 4-hop direct: ${direct4}ms');
debugPrint(' flood: ${flood}ms');
});
test('returns null before minimum observations', () {
for (var i = 0; i < TimeoutPredictionService.minObservations - 1; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000,
);
}
expect(service.hasModel, isFalse);
expect(service.predictTimeout(pathLength: 0, messageBytes: 50), isNull);
});
test('caps observations at maxObservations', () {
for (var i = 0; i < TimeoutPredictionService.maxObservations + 20; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000 + i,
);
}
expect(
service.observationCount,
equals(TimeoutPredictionService.maxObservations),
);
});
test('blends per-contact stats after enough observations', () {
// Train with mixed contacts and varied features:
// contactA is fast (0-hop), contactB is slow (2-hop)
for (var i = 0; i < 12; i++) {
service.recordObservation(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 30 + i,
tripTimeMs: 1500,
);
service.recordObservation(
contactKey: 'contactB',
pathLength: 2,
messageBytes: 30 + i,
tripTimeMs: 8000,
);
}
final predA = service.predictTimeout(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 50,
);
final predB = service.predictTimeout(
contactKey: 'contactB',
pathLength: 0,
messageBytes: 50,
);
expect(predA, isNotNull);
expect(predB, isNotNull);
// Contact B (slow) should have a higher predicted timeout than A (fast)
expect(predB!, greaterThan(predA!));
debugPrint('Per-contact blending:');
debugPrint(' contactA (fast): ${predA}ms');
debugPrint(' contactB (slow): ${predB}ms');
});
}
DeliveryObservation _obs({
required int pathLength,
required int messageBytes,
required int deliveryMs,
String contactKey = 'test_contact',
}) {
return DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: 5,
isFlood: pathLength < 0,
deliveryMs: deliveryMs,
timestamp: DateTime.now(),
);
}