Compare commits

..

53 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 60e8ee0130 fix: simplify method call for writing data in UsbSerialService 2026-03-14 18:41:57 -07:00
zjs81 6dfb7a4b69 fix: auto-add flag parsing, contact cache restore, and USB reconnect
- Fix operator precedence bug in _handleAutoAddConfig where `flags &
  flag != 0` was parsed as `flags & (flag != 0)`, always checking bit 0
  instead of the correct flag bit
- Populate _contacts from cache in loadContactCache() so contacts
  persist across app restarts
- Toggle DTR low→high on USB connect to force device to see a fresh
  connection
- Add 10ms inter-frame delay for USB sends to prevent missed responses
- Deassert DTR before closing USB port on disconnect/dispose

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:41:21 -07:00
zjs81 28a423e0a8 fix: correct location validation and clean up target contact handling
- Fix asymmetric lat/lon validation in _handleContactAdvert (was checking
  longitude != 0 for latitude; now uses (latitude != 0 || longitude != 0)
  for both)
- Remove duplicate targetGuessed assignment in path_trace_map
- Rename public target field to private _targetContact, use local variable
  to avoid unnecessary null-aware operators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:14:39 -07:00
Winston Lowe 3593cfa843 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:44 -07:00
Winston Lowe dc85e7a41c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:17 -07:00
Winston Lowe 9265daaf16 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:09 -07:00
Winston Lowe 4b744184c2 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:09:54 -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
Winston Lowe 06a906f4f7 Enhance location handling and improve path trace functionality across screens 2026-03-14 17:51:24 -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
Winston Lowe 24fa78741b add TCP server address and port settings to AppSettings and update TcpScreen 2026-03-14 11:46:05 -07:00
Winston Lowe 79a45c527b Unify contact retrieval by introducing allContacts getter 2026-03-14 11:45:47 -07:00
zjs81 8b280b37be Merge pull request #293 from zjs81/map-set-location-and-connector-improvements
feat: add set-as-my-location from map long-press, connector and UI
2026-03-14 09:55:02 -07:00
zjs81 fa4da979af feat: enhance location update feedback and improve message retry error handling 2026-03-14 09:54:50 -07:00
zjs81 91608ff09e feat: improve message matching logic and update notification IDs for advertisements 2026-03-14 09:44:37 -07:00
zjs81 71f59d23df feat: add set-as-my-location from map long-press, connector and UI improvements
Add "Set as my location" option to the map long-press bottom sheet,
allowing users to set their device position directly from the map.
Includes connector, chat, contacts, and message retry service improvements.
2026-03-14 09:33:37 -07:00
zjs81 e90742be25 Merge pull request #272 from just-stuff-tm/tcp
feat: Add TCP connection support and UI integration
2026-03-13 11:04:11 -07:00
Zach db935a7454 refactor(tcp): promote MeshCoreTcpConnector, fix translations, harden UI
- Replace thin MeshCoreTcpManager facade with a proper MeshCoreTcpConnector
  that owns TcpTransportService and the frame subscription, mirroring
  MeshCoreUsbManager. The connector no longer holds a raw TcpTransportService
  or a _tcpFrameSubscription field.
- Remove hardcoded default host IP from TcpScreen (keep port 5000 hint).
- Disable connect button during scanning state, not just connecting state.
- Fix tcpPortLabel mistranslated as nautical "port/harbor" in de, it, pt,
  nl, sv, sk, sl, zh; fix corrupted Slovak tcpPortHint ("5 000" → "5000").
- Remove unused tcpStatus_connecting string from all 15 locale arb files
  and all generated app_localizations_*.dart files.
- Add extendedPadding to TCP screen FABs to match USB screen.
- Add Key to connect button; update tests to use byKey and assert
  onPressed == null when button is disabled during scanning.
2026-03-13 10:59:09 -07:00
Winston Lowe 1ad5db27ca Merge branch 'main' into tcp 2026-03-12 23:22:30 -07:00
Winston Lowe 81758adc61 Dev discovery (#291)
* Refactor contact handling: replace DiscoveryContact with Contact, update related methods and settings

* Enhance contact handling: include latitude, longitude, and last modified timestamp in contact updates; refactor path handling to accommodate discovered contacts across multiple screens

* Enhance SNRIndicator: include discovered contacts in name resolution for repeaters

* Refactor path handling: replace addReturnPath with buildPath to improve path construction logic and handle target contact types

* Update lib/screens/map_screen.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add localization for "Show Discovery Contacts" in multiple languages and refactor location plausibility check in map screen

* Enhance contact management: update discovered contacts' active status and improve contact handling with flags and raw packet data

* Refactor ChannelsScreen: pass ChannelMessageStore to buildExpandedContent and ensure messages are cleared after channel creation

* Update MapScreen: adjust label zoom threshold and refactor guessed marker building to include labels

* Refactor ChannelsScreen: change channelMessageStore to a private getter and update its usage in buildExpandedContent calls

* Enhance location plausibility check: add latitude and longitude bounds to ensure valid coordinates

* Update lib/connector/meshcore_connector.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor MeshCoreConnector and related stores: update discovered contacts handling, migrate legacy keys, and set public key in community store

* Refactor MeshCoreConnector and ChannelsScreen: update discovered contacts handling and set public key in community store; enhance location plausibility check in MapScreen

* Update CMD_ADD_UPDATE_CONTACT frame format to include optional latitude and longitude fields

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-12 23:08:46 -07:00
Winston Lowe c81791cf1e Migrate legacy storage keys to scoped keys in various store classes (#289) 2026-03-12 08:39:17 -07:00
Winston Lowe 1fba5312a2 Refactor storage classes to include companion's public key (#277)
* Refactor storage classes to include public key handling and improve data loading/saving logic

* Remove redundant publicKeyHex handling from ContactDiscoveryStore and fix key reference in saveContacts method

* Remove unused app_logger import from ContactDiscoveryStore

* Add warning log for empty publicKeyHex in saveChannelMessages method

* Add warning log for empty publicKeyHex in clearMessages method

* Migrate legacy storage keys to scoped keys across multiple stores

* Remove legacy unscoped keys during migration in storage classes

* Update lib/storage/contact_store.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-12 00:14:48 -07:00
just-stuff-tm 2f770bbd53 fix(tcp): reset state on aborted pre-handshake connect 2026-03-10 21:38:35 -04:00
just-stuff-tm 9db79e9d40 test(tcp): harden cancel-race handling and add coverage
- tighten late TCP connect error suppression to manual-cancel disconnecting/disconnected windows
- keep TCP handshake failures surfaced outside explicit cancel flow
- allow TcpScreen connect action when connector is scanning
- add connector-level tests for late-error suppression classifier
- add TcpScreen test covering connect from scanning state
2026-03-10 20:06:05 -04:00
just-stuff-tm 1913a5aa11 fix(tcp): guard connect cancellation race and align USB screen actions
- add connectTcp cancellation guards after socket connect and connect delay so handshake does not proceed when transport/state changed
- ignore late TCP connect errors after manual cancel or transport switch to avoid spurious second disconnect paths
- keep TCP action hidden only on web and show Bluetooth action on USB screen across platforms for navigation consistency
2026-03-10 19:27:39 -04:00
just-stuff-tm 929c1c3d28 fix(tcp): cancel pending connects on disconnect and propagate remote close 2026-03-09 20:39:17 -04:00
just-stuff-tm 7a2bb20bf7 feat: Add TCP connection support and UI integration
- Implemented TCP transport service for native platforms.
- Added TCP connection screen with input fields for host and port.
- Integrated TCP connection options into the scanner and USB screens.
- Updated localization files for new TCP-related strings.
- Added tests for TCP connection flow and error handling.
- Enhanced USB screen to include TCP connection option.
- Improved layout to ensure no overflow in narrow widths for scanner and USB screens.
2026-03-07 20:07:19 -05:00
zjs81 a1b77bb29b Merge pull request #269 from zjs81/dev-latLonFix
Changed contacts latitude and longitude fields to be null until parsed and set
2026-03-07 13:53:09 -07:00
zjs81 4eecfc92dc Merge pull request #252 from just-stuff-tm/feature/usb
Feature/usb
2026-03-07 13:16:39 -07:00
zjs81 90c8cf5f3e Add TODO to switch flserial to official repo 2026-03-07 13:12:45 -07:00
zjs81 06fa176367 Narrow macOS sandbox entitlement to /dev/cu. and /dev/tty. only
The /dev/ prefix granted read/write to all device nodes. The app only
needs access to serial port devices (/dev/cu.* and /dev/tty.*) for USB
LoRa communication.
2026-03-07 13:10:42 -07:00
zjs81 e4285774a0 Merge branch 'main' into feature/usb 2026-03-07 13:03:15 -07:00
zjs81 b2da695102 Run dart format 2026-03-07 13:01:27 -07:00
zjs81 e1327a93c7 Fix contact sync fallback when channel 0 never arrives
On web BLE, contact sync is deferred until channel 0 arrives via
_handleChannelInfo. If channel 0 times out or channel sync completes
without it, _pendingInitialContactsSync stays true and contacts never
load. Add fallback in _cleanupChannelSync to trigger getContacts() if
the flag is still set when channel sync ends.
2026-03-07 13:00:23 -07:00
zjs81 421bc71bb7 Enhance USB port opening and reading logic with improved error handling and debug logging 2026-03-07 12:55:15 -07:00
Winston Lowe 84ec139ce6 Add latitude and longitude fields to contact handling in MeshCoreConnector 2026-03-07 11:02:47 -08:00
Winston Lowe b748b96237 Enhance contact handling logic in MeshCoreConnector to support conditional addition based on auto-add settings (#268) 2026-03-07 01:45:53 -08:00
Winston Lowe c2671ac2ae Refactor data handling of contacts (#267)
* Refactor data handling in MeshCoreConnector and BufferReader for improved readability and efficiency

* Update lib/connector/meshcore_connector.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix pointer tracking in BufferReader's readCString method

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-07 01:23:46 -08:00
104 changed files with 8262 additions and 1360 deletions
+1
View File
@@ -0,0 +1 @@
6.2.4
+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
+499 -76
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import 'package:meshcore_open/models/discovery_contact.dart';
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
@@ -20,8 +19,10 @@ import '../services/message_retry_service.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/background_service.dart'; import '../services/background_service.dart';
import '../services/timeout_prediction_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'meshcore_connector_usb.dart'; import 'meshcore_connector_usb.dart';
import 'meshcore_connector_tcp.dart';
import '../storage/channel_message_store.dart'; import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart'; import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart'; import '../storage/channel_settings_store.dart';
@@ -86,7 +87,7 @@ enum MeshCoreConnectionState {
disconnecting, disconnecting,
} }
enum MeshCoreTransportType { bluetooth, usb } enum MeshCoreTransportType { bluetooth, usb, tcp }
class RepeaterBatterySnapshot { class RepeaterBatterySnapshot {
final int millivolts; final int millivolts;
@@ -116,11 +117,12 @@ class MeshCoreConnector extends ChangeNotifier {
bool _manualDisconnect = false; bool _manualDisconnect = false;
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager(); final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
StreamSubscription<Uint8List>? _usbFrameSubscription; StreamSubscription<Uint8List>? _usbFrameSubscription;
final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector();
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
final List<ScanResult> _scanResults = []; final List<ScanResult> _scanResults = [];
final List<Contact> _contacts = []; final List<Contact> _contacts = [];
final List<DiscoveryContact> _discoveredContacts = []; final List<Contact> _discoveredContacts = [];
final List<Channel> _channels = []; final List<Channel> _channels = [];
final Map<String, List<Message>> _conversations = {}; final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {}; final Map<int, List<ChannelMessage>> _channelMessages = {};
@@ -165,6 +167,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isLoadingContacts = false; bool _isLoadingContacts = false;
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = false; bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService;
// Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now();
bool _batteryRequested = false; bool _batteryRequested = false;
bool _awaitingSelfInfo = false; bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false; bool _hasReceivedDeviceInfo = false;
@@ -198,6 +204,9 @@ class MeshCoreConnector extends ChangeNotifier {
int _queueSyncRetries = 0; int _queueSyncRetries = 0;
static const int _maxQueueSyncRetries = 3; static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
// Serializes path operations (setContactPath/clearContactPath) to prevent
// interleaved async calls from leaving in-memory state inconsistent with device.
Future<void> _pathOpLock = Future.value();
Map<String, String>? _currentCustomVars; Map<String, String>? _currentCustomVars;
// Channel syncing state (sequential pattern) // Channel syncing state (sequential pattern)
@@ -255,6 +264,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isUsbTransportConnected => bool get isUsbTransportConnected =>
_state == MeshCoreConnectionState.connected && _state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.usb; _activeTransport == MeshCoreTransportType.usb;
String? get activeTcpEndpoint => _tcpConnector.activeEndpoint;
bool get isTcpTransportConnected =>
_state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.tcp;
String get deviceDisplayName { String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) { if (_selfName != null && _selfName!.isNotEmpty) {
@@ -281,7 +294,11 @@ class MeshCoreConnector extends ChangeNotifier {
); );
} }
List<DiscoveryContact> get discoveredContacts { List<Contact> get allContacts => List.unmodifiable([
..._contacts,
..._discoveredContacts.where((c) => !c.isActive),
]);
List<Contact> get discoveredContacts {
return List.unmodifiable(_discoveredContacts); return List.unmodifiable(_discoveredContacts);
} }
@@ -291,6 +308,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isLoadingChannels => _isLoadingChannels; bool get isLoadingChannels => _isLoadingChannels;
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream; Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey; Uint8List? get selfPublicKey => _selfPublicKey;
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
String? get selfName => _selfName; String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude; double? get selfLatitude => _selfLatitude;
double? get selfLongitude => _selfLongitude; double? get selfLongitude => _selfLongitude;
@@ -306,6 +324,11 @@ class MeshCoreConnector extends ChangeNotifier {
bool? get autoAddRoomServers => _autoAddRoomServers; bool? get autoAddRoomServers => _autoAddRoomServers;
bool? get autoAddSensors => _autoAddSensors; bool? get autoAddSensors => _autoAddSensors;
bool? get autoAddOverwriteOldest => _overwriteOldest; bool? get autoAddOverwriteOldest => _overwriteOldest;
int get telemetryModeBase => _telemetryModeBase;
int get telemetryModeLoc => _telemetryModeLoc;
int get telemetryModeEnv => _telemetryModeEnv;
int get advertLocationPolicy => _advertLocPolicy;
int get multiAcks => _multiAcks;
bool? get clientRepeat => _clientRepeat; bool? get clientRepeat => _clientRepeat;
int? get firmwareVerCode => _firmwareVerCode; int? get firmwareVerCode => _firmwareVerCode;
Map<String, String>? get currentCustomVars => _currentCustomVars; Map<String, String>? get currentCustomVars => _currentCustomVars;
@@ -552,6 +575,10 @@ class MeshCoreConnector extends ChangeNotifier {
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
); );
_notificationService.clearContactNotification(
contactKeyHex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -570,6 +597,10 @@ class MeshCoreConnector extends ChangeNotifier {
_channels.isNotEmpty ? _channels : _cachedChannels, _channels.isNotEmpty ? _channels : _cachedChannels,
), ),
); );
_notificationService.clearChannelNotification(
channelIndex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -651,6 +682,7 @@ class MeshCoreConnector extends ChangeNotifier {
BleDebugLogService? bleDebugLogService, BleDebugLogService? bleDebugLogService,
AppDebugLogService? appDebugLogService, AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService, BackgroundService? backgroundService,
TimeoutPredictionService? timeoutPredictionService,
}) { }) {
_retryService = retryService; _retryService = retryService;
_pathHistoryService = pathHistoryService; _pathHistoryService = pathHistoryService;
@@ -658,7 +690,9 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService; _bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService; _appDebugLogService = appDebugLogService;
_backgroundService = backgroundService; _backgroundService = backgroundService;
_timeoutPredictionService = timeoutPredictionService;
_usbManager.setDebugLogService(_appDebugLogService); _usbManager.setDebugLogService(_appDebugLogService);
_tcpConnector.setDebugLogService(_appDebugLogService);
// Initialize notification service // Initialize notification service
_notificationService.initialize(); _notificationService.initialize();
@@ -671,13 +705,28 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessageCallback: _updateMessage, updateMessageCallback: _updateMessage,
clearContactPathCallback: clearContactPath, clearContactPathCallback: clearContactPath,
setContactPathCallback: setContactPath, setContactPathCallback: setContactPath,
calculateTimeoutCallback: (pathLength, messageBytes) => calculateTimeoutCallback:
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes), (pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
pathLength: pathLength,
messageBytes: messageBytes,
contactKey: contactKey,
),
getSelfPublicKeyCallback: () => _selfPublicKey, getSelfPublicKeyCallback: () => _selfPublicKey,
prepareContactOutboundTextCallback: prepareContactOutboundText, prepareContactOutboundTextCallback: prepareContactOutboundText,
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
debugLogService: _appDebugLogService, debugLogService: _appDebugLogService,
recordPathResultCallback: _recordPathResult, recordPathResultCallback: _recordPathResult,
onDeliveryObservedCallback:
(contactKey, pathLength, messageBytes, tripTimeMs) {
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
_timeoutPredictionService?.recordObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
tripTimeMs: tripTimeMs,
secondsSinceLastRx: secSinceRx,
);
},
); );
} }
@@ -686,12 +735,15 @@ class MeshCoreConnector extends ChangeNotifier {
_knownContactKeys _knownContactKeys
..clear() ..clear()
..addAll(cached.map((c) => c.publicKeyHex)); ..addAll(cached.map((c) => c.publicKeyHex));
_contacts
..clear()
..addAll(cached);
for (final contact in cached) { for (final contact in cached) {
_ensureContactSmazSettingLoaded(contact.publicKeyHex); _ensureContactSmazSettingLoaded(contact.publicKeyHex);
} }
} }
Future<void> loadDiscoveredContactCache() async { Future<void> _loadDiscoveredContactCache() async {
final cached = await _discoveryContactStore.loadContacts(); final cached = await _discoveryContactStore.loadContacts();
_discoveredContacts _discoveredContacts
..clear() ..clear()
@@ -906,10 +958,7 @@ class MeshCoreConnector extends ChangeNotifier {
try { try {
await _usbFrameSubscription?.cancel(); await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null; _usbFrameSubscription = null;
_appDebugLogService?.info( _appDebugLogService?.info('connectUsb: opening serial port…', tag: 'USB');
'connectUsb: opening serial port…',
tag: 'USB',
);
await _usbManager.connect(portName: portName, baudRate: baudRate); await _usbManager.connect(portName: portName, baudRate: baudRate);
_appDebugLogService?.info( _appDebugLogService?.info(
'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}', 'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
@@ -967,6 +1016,142 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
Future<void> connectTcp({required String host, required int port}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
_appDebugLogService?.warn(
'connectTcp ignored: already $_state',
tag: 'TCP',
);
return;
}
_appDebugLogService?.info('connectTcp: endpoint=$host:$port', tag: 'TCP');
await stopScan();
_cancelReconnectTimer();
_manualDisconnect = false;
_resetConnectionHandshakeState();
_activeTransport = MeshCoreTransportType.tcp;
_setState(MeshCoreConnectionState.connecting);
try {
Future<void> handleTcpConnectAbort({required String message}) async {
_appDebugLogService?.warn(message, tag: 'TCP');
final shouldResetState = shouldResetStateAfterTcpConnectAbort(
state: _state,
activeTransport: _activeTransport,
);
if (shouldResetState) {
await disconnect(manual: false);
return;
}
if (_tcpConnector.isConnected) {
await _tcpConnector.disconnect();
}
}
await _tcpConnector.cancelFrameSubscription();
await _tcpConnector.connect(host: host, port: port);
final isTcpConnectCancelled =
_activeTransport != MeshCoreTransportType.tcp ||
_state != MeshCoreConnectionState.connecting ||
!_tcpConnector.isConnected;
if (isTcpConnectCancelled) {
await handleTcpConnectAbort(
message:
'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
);
return;
}
notifyListeners();
await Future<void>.delayed(const Duration(milliseconds: 200));
final isTcpConnectCancelledAfterDelay =
_activeTransport != MeshCoreTransportType.tcp ||
_state != MeshCoreConnectionState.connecting ||
!_tcpConnector.isConnected;
if (isTcpConnectCancelledAfterDelay) {
await handleTcpConnectAbort(
message:
'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
);
return;
}
_tcpConnector.listenFrames(
onFrame: _handleFrame,
onError: (error, stackTrace) {
_appDebugLogService?.error('TCP transport error: $error', tag: 'TCP');
unawaited(disconnect(manual: false));
},
onDone: () {
_appDebugLogService?.warn('TCP frame stream ended', tag: 'TCP');
unawaited(disconnect(manual: false));
},
);
_setState(MeshCoreConnectionState.connected);
_pendingInitialChannelSync = true;
await _requestDeviceInfo();
_startBatteryPolling();
var gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
if (!gotSelfInfo) {
await refreshDeviceInfo();
gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
}
if (!gotSelfInfo) {
throw StateError('Timed out waiting for SELF_INFO during TCP connect');
}
await syncTime();
} catch (error) {
_appDebugLogService?.error('TCP connection error: $error', tag: 'TCP');
final tcpConnectCancelledBeforeHandshake =
shouldIgnoreLateTcpConnectError(
manualDisconnect: _manualDisconnect,
state: _state,
activeTransport: _activeTransport,
tcpManagerConnected: _tcpConnector.isConnected,
);
if (tcpConnectCancelledBeforeHandshake) {
_appDebugLogService?.info(
'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport',
tag: 'TCP',
);
return;
}
await disconnect(manual: false);
rethrow;
}
}
@visibleForTesting
static bool shouldIgnoreLateTcpConnectError({
required bool manualDisconnect,
required MeshCoreConnectionState state,
required MeshCoreTransportType activeTransport,
required bool tcpManagerConnected,
}) {
return manualDisconnect &&
(state == MeshCoreConnectionState.disconnected ||
state == MeshCoreConnectionState.disconnecting) &&
(activeTransport != MeshCoreTransportType.tcp || !tcpManagerConnected);
}
@visibleForTesting
static bool shouldResetStateAfterTcpConnectAbort({
required MeshCoreConnectionState state,
required MeshCoreTransportType activeTransport,
}) {
return state == MeshCoreConnectionState.connecting &&
activeTransport == MeshCoreTransportType.tcp;
}
Future<void> connect(BluetoothDevice device, {String? displayName}) async { Future<void> connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting || if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) { _state == MeshCoreConnectionState.connected) {
@@ -1196,7 +1381,6 @@ class MeshCoreConnector extends ChangeNotifier {
await _requestDeviceInfo(); await _requestDeviceInfo();
_startBatteryPolling(); _startBatteryPolling();
unawaited(loadDiscoveredContactCache());
final gotSelfInfo = await _waitForSelfInfo( final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3), timeout: const Duration(seconds: 3),
@@ -1233,6 +1417,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get _shouldGateInitialChannelSync => bool get _shouldGateInitialChannelSync =>
_activeTransport == MeshCoreTransportType.usb || _activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp ||
(_activeTransport == MeshCoreTransportType.bluetooth && (_activeTransport == MeshCoreTransportType.bluetooth &&
PlatformInfo.isWeb); PlatformInfo.isWeb);
@@ -1279,9 +1464,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> disconnect({bool manual = true}) async { Future<void> disconnect({bool manual = true}) async {
if (_state == MeshCoreConnectionState.disconnecting) return; if (_state == MeshCoreConnectionState.disconnecting) return;
final transportAtDisconnect = _activeTransport; final transportAtDisconnect = _activeTransport;
final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb final transportLabel = switch (transportAtDisconnect) {
? 'USB' MeshCoreTransportType.bluetooth => 'BLE',
: 'BLE'; MeshCoreTransportType.usb => 'USB',
MeshCoreTransportType.tcp => 'TCP',
};
_appDebugLogService?.info( _appDebugLogService?.info(
'Starting disconnect transport=$transportLabel manual=$manual', 'Starting disconnect transport=$transportLabel manual=$manual',
@@ -1301,6 +1488,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbFrameSubscription?.cancel(); await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null; _usbFrameSubscription = null;
await _usbManager.disconnect(); await _usbManager.disconnect();
await _tcpConnector.disconnect();
await _notifySubscription?.cancel(); await _notifySubscription?.cancel();
_notifySubscription = null; _notifySubscription = null;
@@ -1382,6 +1570,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (_activeTransport == MeshCoreTransportType.usb) { if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data); await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else { } else {
if (_rxCharacteristic == null) { if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available"); throw Exception("MeshCore RX characteristic not available");
@@ -1595,6 +1789,13 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List customPath, Uint8List customPath,
int pathLen, int pathLen,
) async { ) async {
// Serialize path operations to prevent interleaved async calls from
// leaving in-memory state inconsistent with the device.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return; if (!isConnected) return;
await sendFrame( await sendFrame(
@@ -1607,15 +1808,46 @@ class MeshCoreConnector extends ChangeNotifier {
name: contact.name, name: contact.name,
), ),
); );
// USB writes return instantly (no BLE flow control), so give the firmware
// time to persist the path change before subsequent commands.
if (_activeTransport == MeshCoreTransportType.usb) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
} finally {
completer.complete();
}
} }
Future<void> setContactFavorite(Contact contact, bool isFavorite) async { Future<void> setContactFlags(
Contact contact, {
bool? isFavorite,
bool? teleBase,
bool? teleLoc,
bool? teleEnv,
}) async {
if (!isConnected) return; if (!isConnected) return;
final latestContact = final latestContact =
await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact; await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact;
final updatedFlags = isFavorite int updatedFlags = isFavorite != null
? (isFavorite
? (latestContact.flags | contactFlagFavorite) ? (latestContact.flags | contactFlagFavorite)
: (latestContact.flags & ~contactFlagFavorite); : (latestContact.flags & ~contactFlagFavorite))
: latestContact.flags;
updatedFlags = teleBase != null
? (teleBase
? (updatedFlags | contactFlagTeleBase)
: (updatedFlags & ~contactFlagTeleBase))
: updatedFlags;
updatedFlags = teleLoc != null
? (teleLoc
? (updatedFlags | contactFlagTeleLoc)
: (updatedFlags & ~contactFlagTeleLoc))
: updatedFlags;
updatedFlags = teleEnv != null
? (teleEnv
? (updatedFlags | contactFlagTeleEnv)
: (updatedFlags & ~contactFlagTeleEnv))
: updatedFlags;
await sendFrame( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
@@ -1906,7 +2138,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> removeContact(Contact contact) async { Future<void> removeContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
_handleDiscovery(contact, Uint8List(0), noNotify: true); _handleDiscovery(
contact,
contact.rawPacket ?? Uint8List(0),
noNotify: true,
);
await sendFrame(buildRemoveContactFrame(contact.publicKey)); await sendFrame(buildRemoveContactFrame(contact.publicKey));
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex); _contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
@@ -1922,7 +2158,20 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> removeDiscoveredContact(DiscoveryContact contact) async { Future<void> updateKnownDiscovered() async {
if (!isConnected) return;
for (int i = 0; i < _discoveredContacts.length; i++) {
_discoveredContacts[i] = _discoveredContacts[i].copyWith(
isActive: _knownContactKeys.contains(
_discoveredContacts[i].publicKeyHex,
),
);
}
unawaited(_persistDiscoveredContacts());
notifyListeners();
}
Future<void> removeDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
_discoveredContacts.removeWhere( _discoveredContacts.removeWhere(
(c) => c.publicKeyHex == contact.publicKeyHex, (c) => c.publicKeyHex == contact.publicKeyHex,
@@ -1931,7 +2180,7 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> importDiscoveredContact(DiscoveryContact contact) async { Future<void> importDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
await sendFrame( await sendFrame(
@@ -1940,11 +2189,23 @@ class MeshCoreConnector extends ChangeNotifier {
contact.path, contact.path,
contact.pathLength, contact.pathLength,
type: contact.type, type: contact.type,
flags: 0, flags: contact.flags,
name: contact.name, name: contact.name,
lat: contact.latitude,
lon: contact.longitude,
lastModified: contact.lastSeen,
), ),
); );
// Update the discovered contact to mark it as active (imported)
final discoveredIndex = _discoveredContacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (discoveredIndex >= 0) {
_discoveredContacts[discoveredIndex] =
_discoveredContacts[discoveredIndex].copyWith(isActive: true);
}
_handleContactAdvert( _handleContactAdvert(
Contact( Contact(
publicKey: contact.publicKey, publicKey: contact.publicKey,
@@ -1952,22 +2213,34 @@ class MeshCoreConnector extends ChangeNotifier {
type: contact.type, type: contact.type,
pathLength: contact.pathLength, pathLength: contact.pathLength,
path: contact.path, path: contact.path,
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: DateTime.now(), lastSeen: DateTime.now(),
flags: contact.flags,
), ),
); );
notifyListeners(); notifyListeners();
} }
Future<void> clearContactPath(Contact contact) async { Future<void> clearContactPath(Contact contact) async {
// Serialize path operations to prevent interleaved async calls.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return; if (!isConnected) return;
await sendFrame(buildResetPathFrame(contact.publicKey)); await sendFrame(buildResetPathFrame(contact.publicKey));
if (_activeTransport == MeshCoreTransportType.usb) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
final existingIndex = _contacts.indexWhere( final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex, (c) => c.publicKeyHex == contact.publicKeyHex,
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
final existing = _contacts[existingIndex]; final existing = _contacts[existingIndex];
// Use copyWith to preserve pathOverride and pathOverrideBytes // Preserve pathOverride and pathOverrideBytes — only reset device path
_contacts[existingIndex] = existing.copyWith( _contacts[existingIndex] = existing.copyWith(
pathLength: -1, pathLength: -1,
path: Uint8List(0), path: Uint8List(0),
@@ -1975,7 +2248,9 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
unawaited(_persistContacts()); unawaited(_persistContacts());
} }
// The device will send updated contact info with path_len = -1 } finally {
completer.complete();
}
} }
void updateContactInMemory( void updateContactInMemory(
@@ -2109,6 +2384,31 @@ class MeshCoreConnector extends ChangeNotifier {
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
} }
Future<void> setTelemetryModeBase(
int base,
int location,
int env,
int advert,
int multiAcks,
) async {
if (!isConnected) return;
_telemetryModeBase = base.clamp(teleModeDeny, teleModeAllowAll).toInt();
_telemetryModeLoc = location.clamp(teleModeDeny, teleModeAllowAll).toInt();
_telemetryModeEnv = env.clamp(teleModeDeny, teleModeAllowAll).toInt();
_advertLocPolicy = advert.clamp(0, 1).toInt();
_multiAcks = multiAcks.clamp(0, 2).toInt();
await sendFrame(
buildSetOtherParamsFrame(
(_telemetryModeEnv << 4) |
(_telemetryModeLoc << 2) |
_telemetryModeBase,
_advertLocPolicy,
_multiAcks,
),
);
notifyListeners();
}
Future<void> getChannels({int? maxChannels, bool force = false}) async { Future<void> getChannels({int? maxChannels, bool force = false}) async {
if (!isConnected) return; if (!isConnected) return;
if (_isSyncingChannels) { if (_isSyncingChannels) {
@@ -2250,6 +2550,14 @@ class MeshCoreConnector extends ChangeNotifier {
_hasLoadedChannels = true; _hasLoadedChannels = true;
_previousChannelsCache.clear(); _previousChannelsCache.clear();
} }
// Fallback: if contact sync was deferred waiting for channel 0 but
// channel sync finished without triggering it, start contacts now.
if (_pendingInitialContactsSync && isConnected) {
_pendingInitialContactsSync = false;
unawaited(getContacts());
}
// Keep cache on failure/disconnection for future attempts // Keep cache on failure/disconnection for future attempts
} }
@@ -2276,6 +2584,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleFrame(List<int> data) { void _handleFrame(List<int> data) {
if (data.isEmpty) return; if (data.isEmpty) return;
_lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data); final frame = Uint8List.fromList(data);
_receivedFramesController.add(frame); _receivedFramesController.add(frame);
@@ -2303,10 +2612,13 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true; _isLoadingContacts = true;
notifyListeners(); notifyListeners();
break; break;
case pushCodeAdvert:
// Known contact was seen again - just a pub key, no action needed
break;
case pushCodeNewAdvert: case pushCodeNewAdvert:
debugPrint('Got New CONTACT'); debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler // It's the same format as respCodeContact, so we can reuse the handler
_handleContact(frame); _handleContact(frame, isContact: false);
break; break;
case respCodeContact: case respCodeContact:
debugPrint('Got CONTACT'); debugPrint('Got CONTACT');
@@ -2316,6 +2628,7 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('Got END_OF_CONTACTS'); debugPrint('Got END_OF_CONTACTS');
_isLoadingContacts = false; _isLoadingContacts = false;
_preserveContactsOnRefresh = false; _preserveContactsOnRefresh = false;
unawaited(updateKnownDiscovered());
notifyListeners(); notifyListeners();
unawaited(_persistContacts()); unawaited(_persistContacts());
if (PlatformInfo.isWeb && if (PlatformInfo.isWeb &&
@@ -2331,7 +2644,8 @@ class MeshCoreConnector extends ChangeNotifier {
} }
if (_pendingDeferredChannelSyncAfterContacts && if (_pendingDeferredChannelSyncAfterContacts &&
(_activeTransport == MeshCoreTransportType.bluetooth || (_activeTransport == MeshCoreTransportType.bluetooth ||
_activeTransport == MeshCoreTransportType.usb)) { _activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp)) {
_pendingDeferredChannelSyncAfterContacts = false; _pendingDeferredChannelSyncAfterContacts = false;
_pendingInitialChannelSync = false; _pendingInitialChannelSync = false;
unawaited(getChannels()); unawaited(getChannels());
@@ -2482,6 +2796,28 @@ class MeshCoreConnector extends ChangeNotifier {
selfName.isNotEmpty) { selfName.isNotEmpty) {
_usbManager.updateConnectedLabel(selfName); _usbManager.updateConnectedLabel(selfName);
} }
//set all the stores' public key so they can load the correct data
_channelMessageStore.setPublicKeyHex = selfPublicKeyHex;
_messageStore.setPublicKeyHex = selfPublicKeyHex;
_channelOrderStore.setPublicKeyHex = selfPublicKeyHex;
_channelSettingsStore.setPublicKeyHex = selfPublicKeyHex;
_contactSettingsStore.setPublicKeyHex = selfPublicKeyHex;
_contactStore.setPublicKeyHex = selfPublicKeyHex;
_channelStore.setPublicKeyHex = selfPublicKeyHex;
_unreadStore.setPublicKeyHex = selfPublicKeyHex;
// Now that we have self info, we can load all the persisted data for this node
_loadChannelOrder();
loadContactCache();
loadChannelSettings();
loadCachedChannels();
// Load persisted channel messages
loadAllChannelMessages();
loadUnreadState();
_loadDiscoveredContactCache();
_awaitingSelfInfo = false; _awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null; _selfInfoRetryTimer = null;
@@ -2498,14 +2834,16 @@ class MeshCoreConnector extends ChangeNotifier {
if (PlatformInfo.isWeb && if (PlatformInfo.isWeb &&
_activeTransport == MeshCoreTransportType.bluetooth) { _activeTransport == MeshCoreTransportType.bluetooth) {
_pendingInitialContactsSync = true; _pendingInitialContactsSync = true;
} else if (_activeTransport == MeshCoreTransportType.usb) { } else if (_activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp) {
_pendingDeferredChannelSyncAfterContacts = true; _pendingDeferredChannelSyncAfterContacts = true;
getContacts(); getContacts();
} else { } else {
getContacts(); getContacts();
} }
if (_shouldGateInitialChannelSync && if (_shouldGateInitialChannelSync &&
_activeTransport != MeshCoreTransportType.usb) { _activeTransport != MeshCoreTransportType.usb &&
_activeTransport != MeshCoreTransportType.tcp) {
_maybeStartInitialChannelSync(); _maybeStartInitialChannelSync();
} }
} }
@@ -2623,41 +2961,73 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
/// Calculate timeout for a message based on radio settings and path length /// Estimate single-packet airtime in ms from radio settings, or a fallback.
/// Returns timeout in milliseconds, considering number of hops int _estimateAirtimeMs(int messageBytes) {
int calculateTimeout({required int pathLength, int messageBytes = 100}) {
// If we have radio settings, use them for accurate calculation
if (_currentFreqHz != null && if (_currentFreqHz != null &&
_currentBwHz != null && _currentBwHz != null &&
_currentSf != null && _currentSf != null &&
_currentCr != null) { _currentCr != null) {
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4; final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
return calculateMessageTimeout( return calculateLoRaAirtime(
freqHz: _currentFreqHz!, payloadBytes: messageBytes,
bwHz: _currentBwHz!, spreadingFactor: _currentSf!,
sf: _currentSf!, bandwidthHz: _currentBwHz!,
cr: cr, codingRate: cr,
pathLength: pathLength, lowDataRateOptimize: _currentSf! >= 11,
messageBytes: messageBytes,
); );
} }
return 50; // fallback: ~SF7/BW125 for 100 bytes
}
// Fallback: Conservative estimates based on typical settings /// Physics-based worst-case timeout (ceiling).
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes int _physicsMaxTimeout(int pathLength, int airtime) {
const estimatedAirtime = 50;
if (pathLength < 0) { if (pathLength < 0) {
// Flood mode: Base delay + 16× airtime return 500 + (16 * airtime);
return 500 + (16 * estimatedAirtime);
} else { } else {
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1)) return 500 + ((airtime * 6 + 250) * (pathLength + 1));
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
} }
} }
void _handleContact(Uint8List frame) { /// Physics-based minimum timeout (floor): raw traversal time.
int _physicsMinTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
return airtime;
} else {
return airtime * (pathLength + 1);
}
}
/// Calculate timeout for a message based on radio settings and path length.
/// Returns timeout in milliseconds, considering number of hops.
int calculateTimeout({
required int pathLength,
int messageBytes = 100,
String? contactKey,
}) {
final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime);
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
// Try ML-based prediction, clamped between physics bounds
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
final mlTimeout = _timeoutPredictionService?.predictTimeout(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secSinceRx,
);
if (mlTimeout != null) {
return mlTimeout.clamp(physicsMin, physicsMax);
}
return physicsMax;
}
void _handleContact(Uint8List frame, {bool isContact = true}) {
final contact = Contact.fromFrame(frame); final contact = Contact.fromFrame(frame);
if (contact != null) { if (contact != null) {
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
@@ -2694,11 +3064,23 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'Connector', tag: 'Connector',
); );
} else { } else {
if ((_autoAddUsers && contact.type == advTypeChat) ||
(_autoAddRepeaters && contact.type == advTypeRepeater) ||
(_autoAddRoomServers && contact.type == advTypeRoom) ||
(_autoAddSensors && contact.type == advTypeSensor) ||
isContact) {
_contacts.add(contact); _contacts.add(contact);
appLogger.info( appLogger.info(
'Added new contact ${contact.name}: pathLen=${contact.pathLength}', 'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
tag: 'Connector', tag: 'Connector',
); );
} else {
appLogger.info(
"Discovered contact ${contact.name} (type ${contact.typeLabel}) not added due to auto-add settings",
tag: 'Connector',
);
return;
}
} }
_knownContactKeys.add(contact.publicKeyHex); _knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex); _loadMessagesForContact(contact.publicKeyHex);
@@ -4261,6 +4643,7 @@ class MeshCoreConnector extends ChangeNotifier {
_batteryPollTimer?.cancel(); _batteryPollTimer?.cancel();
_receivedFramesController.close(); _receivedFramesController.close();
_usbManager.dispose(); _usbManager.dispose();
_tcpConnector.dispose();
// Flush pending unread writes before disposal // Flush pending unread writes before disposal
_unreadStore.flush(); _unreadStore.flush();
@@ -4270,31 +4653,23 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleRxData(Uint8List frame) { void _handleRxData(Uint8List frame) {
final packet = BufferReader(frame); final packet = BufferReader(frame);
double snr = 0.0;
int routeType = 0;
int payloadType = 0;
Uint8List pathBytes = Uint8List(0);
Uint8List payload = Uint8List(0);
try { try {
packet.skipBytes(1); // Skip frame type byte packet.skipBytes(1); // Skip frame type byte
snr = packet.readInt8() / 4.0; final snr = packet.readInt8() / 4.0;
packet.skipBytes(1); // Skip RSSI byte packet.skipBytes(1); // Skip RSSI byte
//final rssi = packet.readByte(); //final rssi = packet.readByte();
final header = packet.readByte(); final header = packet.readByte();
routeType = header & 0x03; final routeType = header & 0x03;
payloadType = (header >> 2) & 0x0F; final payloadType = (header >> 2) & 0x0F;
if (routeType == _routeTransportFlood || if (routeType == _routeTransportFlood ||
routeType == _routeTransportDirect) { routeType == _routeTransportDirect) {
packet.skipBytes(4); // Skip transport-specific bytes packet.skipBytes(4); // Skip transport-specific bytes
} }
//final payloadVer = (header >> 6) & 0x03; //final payloadVer = (header >> 6) & 0x03;
final pathLen = packet.readByte(); final pathLen = packet.readByte();
pathBytes = packet.readBytes(pathLen); final pathBytes = packet.readBytes(pathLen);
payload = packet.readBytes(packet.remaining); final payload = packet.readBytes(packet.remaining);
} catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return;
}
final rawPacket = frame.sublist(3); final rawPacket = frame.sublist(3);
switch (payloadType) { switch (payloadType) {
case payloadTypeADVERT: case payloadTypeADVERT:
@@ -4308,6 +4683,10 @@ class MeshCoreConnector extends ChangeNotifier {
break; break;
default: default:
} }
} catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return;
}
} }
void importContact(Uint8List frame) { void importContact(Uint8List frame) {
@@ -4332,8 +4711,8 @@ class MeshCoreConnector extends ChangeNotifier {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return; return;
} }
double latitude = 0.0; double? latitude;
double longitude = 0.0; double? longitude;
String name = ''; String name = '';
Uint8List publicKey = Uint8List(0); Uint8List publicKey = Uint8List(0);
int type = 0; int type = 0;
@@ -4369,12 +4748,12 @@ class MeshCoreConnector extends ChangeNotifier {
} }
importDiscoveredContact( importDiscoveredContact(
DiscoveryContact( Contact(
rawPacket: frame, rawPacket: frame,
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
pathLength: pathBytes.length, pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
path: Uint8List.fromList( path: Uint8List.fromList(
pathBytes.reversed.toList(), pathBytes.reversed.toList(),
), // Store path in reverse for easier use in outgoing messages ), // Store path in reverse for easier use in outgoing messages
@@ -4393,8 +4772,8 @@ class MeshCoreConnector extends ChangeNotifier {
double snr, double snr,
) { ) {
final advert = BufferReader(payload); final advert = BufferReader(payload);
double latitude = 0.0; double? latitude;
double longitude = 0.0; double? longitude;
String name = ''; String name = '';
String contactKeyHex = ''; String contactKeyHex = '';
Uint8List publicKey = Uint8List(0); Uint8List publicKey = Uint8List(0);
@@ -4440,6 +4819,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (isNewContact) { if (isNewContact) {
final newContact = Contact( final newContact = Contact(
rawPacket: rawPacket,
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
@@ -4456,6 +4836,12 @@ class MeshCoreConnector extends ChangeNotifier {
(_autoAddRoomServers && type == advTypeRoom) || (_autoAddRoomServers && type == advTypeRoom) ||
(_autoAddSensors && type == advTypeSensor)) { (_autoAddSensors && type == advTypeSensor)) {
_handleContactAdvert(newContact); _handleContactAdvert(newContact);
_handleDiscovery(
newContact,
rawPacket,
noNotify: true,
addActive: true,
);
} else { } else {
_handleDiscovery(newContact, rawPacket); _handleDiscovery(newContact, rawPacket);
} }
@@ -4480,8 +4866,20 @@ class MeshCoreConnector extends ChangeNotifier {
// CRITICAL: Preserve user's path override when contact is refreshed from device // CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = existing.copyWith( _contacts[existingIndex] = existing.copyWith(
latitude: hasLocation ? latitude : existing.latitude, latitude:
longitude: hasLocation ? longitude : existing.longitude, hasLocation &&
latitude != null &&
latitude.abs() <= 90 &&
(latitude != 0 || longitude != 0)
? latitude
: existing.latitude,
longitude:
hasLocation &&
longitude != null &&
longitude.abs() <= 180 &&
(latitude != 0 || longitude != 0)
? longitude
: existing.longitude,
name: hasName ? name : existing.name, name: hasName ? name : existing.name,
path: Uint8List.fromList(path.reversed.toList()), path: Uint8List.fromList(path.reversed.toList()),
pathLength: path.length, pathLength: path.length,
@@ -4552,11 +4950,11 @@ class MeshCoreConnector extends ChangeNotifier {
try { try {
reader.skipBytes(1); // Skip the response code byte reader.skipBytes(1); // Skip the response code byte
final flags = reader.readByte(); final flags = reader.readByte();
_autoAddUsers = flags & autoAddChatFlag != 0; _autoAddUsers = (flags & autoAddChatFlag) != 0;
_autoAddRepeaters = flags & autoAddRepeaterFlag != 0; _autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0;
_autoAddRoomServers = flags & autoAddRoomServerFlag != 0; _autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
_autoAddSensors = flags & autoAddSensorFlag != 0; _autoAddSensors = (flags & autoAddSensorFlag) != 0;
_overwriteOldest = flags & autoAddOverwriteOldestFlag != 0; _overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
} catch (e) { } catch (e) {
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector'); appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
} }
@@ -4566,6 +4964,7 @@ class MeshCoreConnector extends ChangeNotifier {
Contact contact, Contact contact,
Uint8List rawPacket, { Uint8List rawPacket, {
bool noNotify = false, bool noNotify = false,
bool addActive = false,
}) { }) {
appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector'); appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
@@ -4585,13 +4984,15 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
flags: 0,
isActive: addActive,
); );
notifyListeners(); notifyListeners();
unawaited(_persistDiscoveredContacts()); unawaited(_persistDiscoveredContacts());
return; return;
} }
final disContact = DiscoveryContact( final disContact = Contact(
rawPacket: rawPacket, rawPacket: rawPacket,
publicKey: contact.publicKey, publicKey: contact.publicKey,
name: contact.name, name: contact.name,
@@ -4601,6 +5002,9 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
lastMessageAt: contact.lastMessageAt,
isActive: addActive,
flags: 0,
); );
_discoveredContacts.add(disContact); _discoveredContacts.add(disContact);
@@ -4624,6 +5028,25 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistDiscoveredContacts()); unawaited(_persistDiscoveredContacts());
notifyListeners(); notifyListeners();
} }
void clearMessagesForContact(Contact contact) {
final contactKeyHex = contact.publicKeyHex;
final messages = _conversations[contactKeyHex];
if (messages == null) return;
messages.clear();
unawaited(_messageStore.saveMessages(contactKeyHex, messages));
markContactRead(contactKeyHex);
notifyListeners();
}
void clearMessagesForChannel(int channelIndex) {
final messages = _channelMessages[channelIndex];
if (messages == null) return;
messages.clear();
unawaited(_channelMessageStore.saveChannelMessages(channelIndex, messages));
markChannelRead(channelIndex);
notifyListeners();
}
} }
const int _phRouteMask = 0x03; const int _phRouteMask = 0x03;
+70
View File
@@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:typed_data';
import '../services/app_debug_log_service.dart';
import '../services/tcp_transport_service.dart';
/// Manages TCP transport for MeshCore devices.
///
/// Owns the [TcpTransportService] and TCP-specific connection state.
/// The main [MeshCoreConnector] delegates all TCP operations here.
class MeshCoreTcpConnector {
final TcpTransportService _service = TcpTransportService();
AppDebugLogService? _debugLog;
StreamSubscription<Uint8List>? _frameSubscription;
// --- Getters ---
String? get activeEndpoint => _service.activeEndpoint;
bool get isConnected => _service.isConnected;
// --- Configuration ---
void setDebugLogService(AppDebugLogService? service) {
_debugLog = service;
_service.setDebugLogService(service);
}
// --- Connection lifecycle ---
Future<void> connect({required String host, required int port}) async {
_debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP');
await _frameSubscription?.cancel();
_frameSubscription = null;
await _service.connect(host: host, port: port);
_debugLog?.info(
'TcpConnector.connect done, endpoint=${_service.activeEndpoint}',
tag: 'TCP',
);
}
StreamSubscription<Uint8List> listenFrames({
required void Function(Uint8List) onFrame,
required void Function(Object, StackTrace?) onError,
required void Function() onDone,
}) {
_frameSubscription = _service.frameStream.listen(
onFrame,
onError: onError,
onDone: onDone,
);
return _frameSubscription!;
}
Future<void> cancelFrameSubscription() async {
await _frameSubscription?.cancel();
_frameSubscription = null;
}
Future<void> disconnect() async {
if (!_service.isConnected && _frameSubscription == null) return;
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
await _frameSubscription?.cancel();
_frameSubscription = null;
await _service.disconnect();
}
Future<void> write(Uint8List data) => _service.write(data);
void dispose() {
_frameSubscription?.cancel();
_service.dispose();
}
}
+10 -3
View File
@@ -24,8 +24,7 @@ class MeshCoreUsbManager {
// --- Configuration --- // --- Configuration ---
Future<List<String>> listPorts() => _service.listPorts(); Future<List<String>> listPorts() => _service.listPorts();
void setRequestPortLabel(String label) => void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
_service.setRequestPortLabel(label);
void setFallbackDeviceName(String label) => void setFallbackDeviceName(String label) =>
_service.setFallbackDeviceName(label); _service.setFallbackDeviceName(label);
@@ -36,7 +35,10 @@ class MeshCoreUsbManager {
} }
// --- Connection lifecycle --- // --- Connection lifecycle ---
Future<void> connect({required String portName, int baudRate = 115200}) async { Future<void> connect({
required String portName,
int baudRate = 115200,
}) async {
_debugLog?.info( _debugLog?.info(
'UsbManager.connect: portName=$portName baud=$baudRate', 'UsbManager.connect: portName=$portName baud=$baudRate',
tag: 'USB', tag: 'USB',
@@ -51,6 +53,9 @@ class MeshCoreUsbManager {
} }
Future<void> disconnect() async { Future<void> disconnect() async {
if (!_service.isConnected && _activePortKey == null) {
return;
}
_debugLog?.info('UsbManager.disconnect', tag: 'USB'); _debugLog?.info('UsbManager.disconnect', tag: 'USB');
await _service.disconnect(); await _service.disconnect();
_activePortKey = null; _activePortKey = null;
@@ -59,6 +64,8 @@ class MeshCoreUsbManager {
Future<void> write(Uint8List data) => _service.write(data); Future<void> write(Uint8List data) => _service.write(data);
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
// --- Label management --- // --- Label management ---
void updateConnectedLabel(String selfName) { void updateConnectedLabel(String selfName) {
_service.updateConnectedLabel(selfName); _service.updateConnectedLabel(selfName);
+90 -15
View File
@@ -4,6 +4,7 @@ import 'dart:typed_data';
// Buffer Reader - sequential binary data reader with pointer tracking // Buffer Reader - sequential binary data reader with pointer tracking
class BufferReader { class BufferReader {
int _pointer = 0; int _pointer = 0;
int _lastPointer = 0;
final Uint8List _buffer; final Uint8List _buffer;
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data); BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
@@ -13,6 +14,7 @@ class BufferReader {
int readByte() => readBytes(1)[0]; int readByte() => readBytes(1)[0];
Uint8List readBytes(int count) { Uint8List readBytes(int count) {
_lastPointer = _pointer;
if (_pointer + count > _buffer.length) { if (_pointer + count > _buffer.length) {
throw RangeError( throw RangeError(
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', 'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
@@ -24,6 +26,7 @@ class BufferReader {
} }
void skipBytes(int count) { void skipBytes(int count) {
_lastPointer = _pointer;
if (_pointer + count > _buffer.length) { if (_pointer + count > _buffer.length) {
throw RangeError( throw RangeError(
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', 'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
@@ -35,6 +38,7 @@ class BufferReader {
Uint8List readRemainingBytes() => readBytes(remaining); Uint8List readRemainingBytes() => readBytes(remaining);
String readString() { String readString() {
_lastPointer = _pointer;
final value = readRemainingBytes(); final value = readRemainingBytes();
try { try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true); return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
@@ -43,7 +47,8 @@ class BufferReader {
} }
} }
String readCString(int maxLength) { String readCStringGreedy(int maxLength) {
_lastPointer = _pointer;
final value = <int>[]; final value = <int>[];
final bytes = readBytes(maxLength); final bytes = readBytes(maxLength);
for (final byte in bytes) { for (final byte in bytes) {
@@ -57,6 +62,24 @@ class BufferReader {
} }
} }
String readCString(int maxLength) {
final backupPointer = _pointer;
final value = <int>[];
int counter = 0;
while (counter < maxLength) {
final byte = readByte();
if (byte == 0) break;
value.add(byte);
counter++;
}
_lastPointer = backupPointer;
try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
} catch (e) {
return String.fromCharCodes(value); // Latin-1 fallback
}
}
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0); int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0); int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
int readUInt16LE() => int readUInt16LE() =>
@@ -78,6 +101,9 @@ class BufferReader {
if ((value & 0x800000) != 0) value -= 0x1000000; if ((value & 0x800000) != 0) value -= 0x1000000;
return value; return value;
} }
void resetPointer() => _pointer = 0;
void rewind() => _pointer = _lastPointer;
} }
// Buffer Writer - accumulating binary data builder // Buffer Writer - accumulating binary data builder
@@ -122,6 +148,19 @@ class BufferWriter {
void writeHex(String hex) { void writeHex(String hex) {
writeBytes(hex2Uint8List(hex)); writeBytes(hex2Uint8List(hex));
} }
void writeBytesPadded(Uint8List bytes, int totalLength) {
// Path data (64 bytes, zero-padded)
final bytesPadded = Uint8List(totalLength);
final len = bytes.length < totalLength ? bytes.length : totalLength;
if (bytes.isNotEmpty && len > 0) {
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
for (int i = 0; i < copyLen; i++) {
bytesPadded[i] = bytes[i];
}
}
writeBytes(bytesPadded);
}
} }
Uint8List hex2Uint8List(String hex) { Uint8List hex2Uint8List(String hex) {
@@ -171,7 +210,7 @@ const int cmdSetChannel = 32;
const int cmdSendTracePath = 36; const int cmdSendTracePath = 36;
const int cmdSetOtherParams = 38; const int cmdSetOtherParams = 38;
const int cmdSendAnonReq = 57; const int cmdSendAnonReq = 57;
const int cmdGetTelemetryReq = 39; const int cmdSendTelemetryReq = 39;
const int cmdGetCustomVar = 40; const int cmdGetCustomVar = 40;
const int cmdSetCustomVar = 41; const int cmdSetCustomVar = 41;
const int cmdSendBinaryReq = 50; const int cmdSendBinaryReq = 50;
@@ -233,6 +272,10 @@ const int advTypeRepeater = 2;
const int advTypeRoom = 3; const int advTypeRoom = 3;
const int advTypeSensor = 4; const int advTypeSensor = 4;
const int teleModeDeny = 0;
const int teleModeAllowFlags = 1; // use contact.flags
const int teleModeAllowAll = 2;
// Payload Types // Payload Types
const int payloadTypeREQ = const int payloadTypeREQ =
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob) 0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
@@ -313,6 +356,9 @@ const int contactPubKeyOffset = 1;
const int contactTypeOffset = 33; const int contactTypeOffset = 33;
const int contactFlagsOffset = 34; const int contactFlagsOffset = 34;
const int contactFlagFavorite = 0x01; const int contactFlagFavorite = 0x01;
const int contactFlagTeleBase = 0x02; // 'base' permission includes battery
const int contactFlagTeleLoc = 0x04;
const int contactFlagTeleEnv = 0x08; //access environment sensors
const int contactPathLenOffset = 35; const int contactPathLenOffset = 35;
const int contactPathOffset = 36; const int contactPathOffset = 36;
const int contactNameOffset = 100; const int contactNameOffset = 100;
@@ -650,14 +696,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
} }
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path // Build CMD_ADD_UPDATE_CONTACT frame to set custom path
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4] // Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
Uint8List buildUpdateContactPathFrame( Uint8List buildUpdateContactPathFrame(
Uint8List pubKey, Uint8List pubKey,
Uint8List customPath, Uint8List path,
int pathLen, { int pathLen, {
int type = 1, // ADV_TYPE_CHAT int type = 1, // ADV_TYPE_CHAT
int flags = 0, int flags = 0,
String name = '', String name = '',
double? lat,
double? lon,
DateTime? lastModified,
}) { }) {
final writer = BufferWriter(); final writer = BufferWriter();
writer.writeByte(cmdAddUpdateContact); writer.writeByte(cmdAddUpdateContact);
@@ -666,17 +715,7 @@ Uint8List buildUpdateContactPathFrame(
writer.writeByte(flags); writer.writeByte(flags);
writer.writeByte(pathLen); writer.writeByte(pathLen);
// Path data (64 bytes, zero-padded) writer.writeBytesPadded(path, maxPathSize);
final pathPadded = Uint8List(maxPathSize);
if (customPath.isNotEmpty && pathLen > 0) {
final copyLen = customPath.length < maxPathSize
? customPath.length
: maxPathSize;
for (int i = 0; i < copyLen; i++) {
pathPadded[i] = customPath[i];
}
}
writer.writeBytes(pathPadded);
// Name (32 bytes, null-padded) // Name (32 bytes, null-padded)
writer.writeCString(name, maxNameSize); writer.writeCString(name, maxNameSize);
@@ -685,6 +724,27 @@ Uint8List buildUpdateContactPathFrame(
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(timestamp); writer.writeUInt32LE(timestamp);
if ((lat == null || lon == null) && lastModified != null) {
// If lat/lon not provided, write zeros
writer.writeInt32LE(0);
writer.writeInt32LE(0);
} else {
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
// Latitude
final latitude = lat ?? 0.0;
writer.writeInt32LE((latitude * 1e6).round());
// Longitude
final longitude = lon ?? 0.0;
writer.writeInt32LE((longitude * 1e6).round());
}
if (lastModified != null) {
// Last modified
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(lastModifiedTimestamp);
}
return writer.toBytes(); return writer.toBytes();
} }
@@ -884,3 +944,18 @@ Uint8List buildSetAutoAddConfigFrame({
writer.writeByte(flags); writer.writeByte(flags);
return writer.toBytes(); return writer.toBytes();
} }
//Build CMD_SEND_TELEMETRY_REQ
// Format: [cmd][reserved x3][pub_key? x32]
Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
final writer = BufferWriter();
writer.writeByte(cmdSendTelemetryReq);
if (pubKey != null && pubKey.length == pubKeySize) {
writer.writeBytes(Uint8List(3)); // reserved bytes
writer.writeBytes(pubKey);
} else {
writer.writeBytes(Uint8List(4)); // reserved bytes
}
return writer.toBytes();
}
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbConnectionFailed": "Неуспешно свързване през USB: {error}", "usbConnectionFailed": "Неуспешно свързване през USB: {error}",
"usbStatus_notConnected": "Изберете USB устройство", "usbStatus_notConnected": "Изберете USB устройство",
"usbStatus_searching": "Търсене на USB устройства...", "usbStatus_searching": "Търсене на USB устройства...",
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка." "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Свържете се чрез TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP адрес",
"tcpPortLabel": "Пристанище",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Въведете крайната точка и свържете се.",
"tcpStatus_connectingTo": "Свързване към {endpoint}...",
"tcpErrorHostRequired": "Необходим е IP адрес.",
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение",
"settings_denyAll": "Откажи всичко",
"settings_allowAll": "Позволи всичко",
"settings_allowByContact": "Позволи по флагове за контакт",
"settings_privacy": "Настройки на поверителността",
"settings_privacySettingsDescription": "Изберете каква информация устройството ви споделя с другите.",
"settings_privacySubtitle": "Контролирайте каква информация се споделя.",
"settings_telemetryBaseMode": "Базов режим на телеметрия",
"settings_telemetryLocationMode": "Режим на местоположение на телеметрията",
"settings_advertLocation": "Място на обявата",
"settings_advertLocationSubtitle": "Включи местоположение в обявата",
"contact_info": "Контактна информация",
"settings_telemetryEnvironmentMode": "Режим на средата на телеметрията",
"contact_telemetry": "Телеметрия",
"contact_lastSeen": "Последно видян",
"contact_clearChat": "Изчисти чата",
"contact_teleBase": "Базата данни за телеметрия",
"contact_settings": "Настройки за контакти",
"contact_teleBaseSubtitle": "Позволи споделяне на ниво на батерията и основна телеметрия",
"contact_teleEnv": "Среда на телеметрия",
"contact_teleLocSubtitle": "Позволи споделяне на данни за местоположение",
"contact_teleLoc": "Местоположение на телеметрията",
"contact_teleEnvSubtitle": "Позволи споделяне на данни от средносферните датчици",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_multiAck": "Мулти-потвърди: {value}",
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен"
} }
+62 -1
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": {
@@ -1887,5 +1888,65 @@
"usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus", "usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
"usbStatus_connecting": "Verbindung zum USB-Gerät...", "usbStatus_connecting": "Verbindung zum USB-Gerät...",
"usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}", "usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}",
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält." "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "IP-Adresse",
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Verbinden über TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.",
"tcpStatus_connectingTo": "Verbindung zu {endpoint}...",
"tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.",
"tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.",
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen",
"settings_allowByContact": "Zulassen durch Kontaktflaggen",
"settings_privacy": "Datenschutzeinstellungen",
"settings_allowAll": "Alles zulassen",
"settings_privacySettingsDescription": "Wählen Sie die Informationen, die Ihr Gerät mit anderen teilt.",
"settings_denyAll": "Alle ablehnen",
"settings_privacySubtitle": "Steuern Sie die Informationen, die freigegeben werden.",
"settings_telemetryLocationMode": "Telemetrie-Ortsmodus",
"settings_telemetryEnvironmentMode": "Telemetrie-Umgebungsmodus",
"settings_advertLocation": "Anzeigenort",
"settings_advertLocationSubtitle": "Ort in der Anzeige einbeziehen",
"settings_telemetryBaseMode": "Telemetrie-Basismodus",
"contact_teleBase": "Telemetriebasis",
"contact_teleBaseSubtitle": "Erlauben des Freigebens des Batteriestands und der grundlegenden Telemetrie",
"contact_teleLoc": "Telemetrieort",
"contact_teleLocSubtitle": "Teilen von Standortdaten zulassen",
"contact_info": "Kontaktinformationen",
"contact_settings": "Kontakteinstellungen",
"contact_telemetry": "Telemetrie",
"contact_teleEnv": "Telemetrieumgebung",
"contact_lastSeen": "Zuletzt gesehen",
"contact_clearChat": "Chat löschen",
"contact_teleEnvSubtitle": "Teilen von Umgebungsensordaten zulassen",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Telemetriemodus aktualisiert",
"settings_multiAck": "Mehrfach-Bestätigungen: {value}"
} }
+61
View File
@@ -49,6 +49,33 @@
"scanner_title": "MeshCore Open", "scanner_title": "MeshCore Open",
"connectionChoiceUsbLabel": "USB", "connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth", "connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Connect over TCP",
"tcpHostLabel": "IP Address",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Enter endpoint and connect",
"tcpStatus_connectingTo": "Connecting to {endpoint}...",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"tcpErrorHostRequired": "IP address is required.",
"tcpErrorPortInvalid": "Port must be between 1 and 65535.",
"tcpErrorUnsupported": "TCP transport is not supported on this platform.",
"tcpErrorTimedOut": "TCP connection timed out.",
"tcpConnectionFailed": "TCP connection failed: {error}",
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbScreenTitle": "Connect over USB", "usbScreenTitle": "Connect over USB",
"usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.", "usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
"usbScreenStatus": "Select a USB device", "usbScreenStatus": "Select a USB device",
@@ -139,6 +166,26 @@
"settings_privacyModeToggle": "Toggle privacy mode to hide your name and location in advertisements.", "settings_privacyModeToggle": "Toggle privacy mode to hide your name and location in advertisements.",
"settings_privacyModeEnabled": "Privacy mode enabled", "settings_privacyModeEnabled": "Privacy mode enabled",
"settings_privacyModeDisabled": "Privacy mode disabled", "settings_privacyModeDisabled": "Privacy mode disabled",
"settings_privacy": "Privacy Settings",
"settings_privacySubtitle": "Control what information is shared.",
"settings_privacySettingsDescription": "Choose what information your device shares with others.",
"settings_denyAll": "Deny all",
"settings_allowByContact": "Allow by contact flags",
"settings_allowAll": "Allow all",
"settings_telemetryBaseMode": "Telemetry Base Mode",
"settings_telemetryLocationMode": "Telemetry Location Mode",
"settings_telemetryEnvironmentMode": "Telemetry Environment Mode",
"settings_advertLocation": "Advert Location",
"settings_advertLocationSubtitle": "Include location in advert.",
"settings_multiAck": "Multi-ACKs: {value}",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Telemetry mode updated",
"settings_actions": "Actions", "settings_actions": "Actions",
"settings_sendAdvertisement": "Send Advertisement", "settings_sendAdvertisement": "Send Advertisement",
"settings_sendAdvertisementSubtitle": "Broadcast presence now", "settings_sendAdvertisementSubtitle": "Broadcast presence now",
@@ -389,6 +436,7 @@
"contacts_newGroup": "New Group", "contacts_newGroup": "New Group",
"contacts_groupName": "Group name", "contacts_groupName": "Group name",
"contacts_groupNameRequired": "Group name is required", "contacts_groupNameRequired": "Group name is required",
"contacts_groupNameReserved": "This group name is reserved",
"contacts_groupAlreadyExists": "Group \"{name}\" already exists", "contacts_groupAlreadyExists": "Group \"{name}\" already exists",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -427,6 +475,17 @@
} }
} }
}, },
"contact_info": "Contact Info",
"contact_settings": "Contact Settings",
"contact_telemetry": "Telemetry",
"contact_lastSeen": "Last seen",
"contact_clearChat": "Clear Chat",
"contact_teleBase": "Telemetry Base",
"contact_teleBaseSubtitle": "Allow sharing battery level and basic telemetry",
"contact_teleLoc": "Telemetry Location",
"contact_teleLocSubtitle": "Allow sharing location data",
"contact_teleEnv": "Telemetry Environment",
"contact_teleEnvSubtitle": "Allow sharing environment sensor data",
"channels_title": "Channels", "channels_title": "Channels",
"channels_noChannelsConfigured": "No channels configured", "channels_noChannelsConfigured": "No channels configured",
"channels_addPublicChannel": "Add Public Channel", "channels_addPublicChannel": "Add Public Channel",
@@ -780,6 +839,7 @@
"map_source": "Source", "map_source": "Source",
"map_flags": "Flags", "map_flags": "Flags",
"map_shareMarkerHere": "Share marker here", "map_shareMarkerHere": "Share marker here",
"map_setAsMyLocation": "Set as my location",
"map_pinLabel": "Pin label", "map_pinLabel": "Pin label",
"map_label": "Label", "map_label": "Label",
"map_pointOfInterest": "Point of interest", "map_pointOfInterest": "Point of interest",
@@ -807,6 +867,7 @@
"map_markers": "Markers", "map_markers": "Markers",
"map_showSharedMarkers": "Show shared markers", "map_showSharedMarkers": "Show shared markers",
"map_showGuessedLocations": "Show guessed node locations", "map_showGuessedLocations": "Show guessed node locations",
"map_showDiscoveryContacts": "Show Discovery Contacts",
"map_guessedLocation": "Guessed location", "map_guessedLocation": "Guessed location",
"map_lastSeenTime": "Last Seen Time", "map_lastSeenTime": "Last Seen Time",
"map_sharedPin": "Shared pin", "map_sharedPin": "Shared pin",
+62 -1
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": {
@@ -1887,5 +1888,65 @@
"usbStatus_searching": "Buscando dispositivos USB...", "usbStatus_searching": "Buscando dispositivos USB...",
"usbStatus_notConnected": "Seleccione un dispositivo USB", "usbStatus_notConnected": "Seleccione un dispositivo USB",
"usbConnectionFailed": "Error al conectar mediante USB: {error}", "usbConnectionFailed": "Error al conectar mediante USB: {error}",
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion." "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpScreenTitle": "Establecer conexión a través de TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "Dirección IP",
"tcpPortLabel": "Puerto",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Ingrese la dirección final y conecte.",
"tcpStatus_connectingTo": "Conectándose a {endpoint}...",
"tcpErrorHostRequired": "Se requiere la dirección IP.",
"tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.",
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
"map_setAsMyLocation": "Establecer mi ubicación",
"settings_privacySubtitle": "Controlar qué información se comparte.",
"settings_allowByContact": "Permitir por banderas de contacto",
"settings_denyAll": "Denegar todo",
"settings_telemetryBaseMode": "Modo base de telemetría",
"settings_telemetryEnvironmentMode": "Modo de entorno de telemetría",
"settings_advertLocationSubtitle": "Incluir ubicación en anuncio",
"contact_info": "Información de contacto",
"settings_privacySettingsDescription": "Elige qué información comparte tu dispositivo con otros.",
"settings_allowAll": "Permitir todo",
"settings_privacy": "Configuración de privacidad",
"contact_settings": "Configuración de contacto",
"settings_telemetryLocationMode": "Modo de ubicación de telemetría",
"contact_teleBase": "Base de Telemetría",
"contact_teleLoc": "Ubicación de telemetría",
"settings_advertLocation": "Ubicación de anuncio",
"contact_teleLocSubtitle": "Permitir el intercambio de datos de ubicación",
"contact_clearChat": "Borrar chat",
"contact_telemetry": "Telemetría",
"contact_lastSeen": "Visto por última vez",
"contact_teleBaseSubtitle": "Permitir el intercambio de nivel de batería y telemetría básica",
"contact_teleEnv": "Entorno de Telemetría",
"contact_teleEnvSubtitle": "Permitir el intercambio de datos de sensores de entorno",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Modo de telemetría actualizado",
"settings_multiAck": "Multi-ACKs: {value}"
} }
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbConnectionFailed": "Échec de la connexion USB : {error}", "usbConnectionFailed": "Échec de la connexion USB : {error}",
"usbStatus_connecting": "Connexion au périphérique USB...", "usbStatus_connecting": "Connexion au périphérique USB...",
"usbStatus_searching": "Recherche de périphériques USB...", "usbStatus_searching": "Recherche de périphériques USB...",
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion." "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Adresse IP",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Établir une connexion via TCP",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.",
"tcpStatus_connectingTo": "Connexion à {endpoint}...",
"tcpErrorHostRequired": "Une adresse IP est obligatoire.",
"tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.",
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
"tcpErrorTimedOut": "La connexion TCP a expiré.",
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
"map_setAsMyLocation": "Définir comme ma localisation",
"settings_privacy": "Paramètres de confidentialité",
"settings_privacySubtitle": "Contrôlez les informations partagées",
"settings_telemetryLocationMode": "Mode d'emplacement de télémétrie",
"settings_telemetryEnvironmentMode": "Mode d'environnement de télémétrie",
"settings_advertLocation": "Emplacement de l'annonce",
"settings_advertLocationSubtitle": "Inclure l'emplacement dans l'annonce",
"settings_denyAll": "Refuser tout",
"settings_allowByContact": "Autoriser par drapeaux de contact",
"settings_privacySettingsDescription": "Choisissez les informations que votre appareil partage avec les autres.",
"settings_allowAll": "Autoriser tout",
"contact_info": "Informations de contact",
"settings_telemetryBaseMode": "Mode de base Télémétrie",
"contact_teleBase": "Base de télémétrie",
"contact_teleLoc": "Emplacement de télémétrie",
"contact_teleLocSubtitle": "Autoriser le partage des données de localisation",
"contact_teleEnv": "Environnement Télémétrie",
"contact_teleEnvSubtitle": "Autoriser le partage des données des capteurs d'environnement",
"contact_telemetry": "Télémétrie",
"contact_settings": "Paramètres de contact",
"contact_lastSeen": "Dernière fois vu",
"contact_clearChat": "Effacer la conversation",
"contact_teleBaseSubtitle": "Autoriser le partage du niveau de batterie et de la télémétrie de base",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_multiAck": "Multi-ACKs : {value}",
"settings_telemetryModeUpdated": "Le mode télémétrie a été mis à jour"
} }
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbConnectionFailed": "Errore nella connessione USB: {error}", "usbConnectionFailed": "Errore nella connessione USB: {error}",
"usbStatus_notConnected": "Seleziona un dispositivo USB", "usbStatus_notConnected": "Seleziona un dispositivo USB",
"usbStatus_connecting": "Connessione al dispositivo USB...", "usbStatus_connecting": "Connessione al dispositivo USB...",
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion." "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Indirizzo IP",
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Stabilire una connessione tramite TCP",
"tcpPortLabel": "Porta",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.",
"tcpStatus_connectingTo": "Connessione a {endpoint}...",
"tcpErrorHostRequired": "È necessario fornire un indirizzo IP.",
"tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.",
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
"map_setAsMyLocation": "Imposta come la mia posizione",
"settings_privacySettingsDescription": "Scegli le informazioni che il tuo dispositivo condivide con gli altri.",
"settings_allowByContact": "Consenti in base ai flag di contatto",
"settings_telemetryLocationMode": "Modalità di posizionamento telemetrico",
"settings_telemetryEnvironmentMode": "Modalità di ambiente di telemetria",
"settings_advertLocation": "Posizione dell'annuncio",
"settings_advertLocationSubtitle": "Includi la posizione nell'annuncio",
"settings_privacy": "Impostazioni sulla privacy",
"settings_denyAll": "Negare tutto",
"settings_privacySubtitle": "Controlla le informazioni che vengono condivise.",
"settings_allowAll": "Consenti tutto",
"contact_info": "Informazioni di Contatto",
"settings_telemetryBaseMode": "Modalità di base di telemetria",
"contact_teleBase": "Base di telemetria",
"contact_teleLoc": "Posizione telemetria",
"contact_teleLocSubtitle": "Consenti la condivisione dei dati di posizione",
"contact_clearChat": "Cancella chat",
"contact_telemetry": "Telemetria",
"contact_settings": "Impostazioni di contatto",
"contact_lastSeen": "Ultimo accesso",
"contact_teleBaseSubtitle": "Consenti la condivisione del livello della batteria e della telemetria di base",
"contact_teleEnvSubtitle": "Consenti la condivisione dei dati del sensore ambientale",
"contact_teleEnv": "Ambiente di telemetria",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Modalità telemetria aggiornata",
"settings_multiAck": "Multi-ACKs: {value}"
} }
+240
View File
@@ -334,6 +334,84 @@ abstract class AppLocalizations {
/// **'Bluetooth'** /// **'Bluetooth'**
String get connectionChoiceBluetoothLabel; String get connectionChoiceBluetoothLabel;
/// No description provided for @connectionChoiceTcpLabel.
///
/// In en, this message translates to:
/// **'TCP'**
String get connectionChoiceTcpLabel;
/// No description provided for @tcpScreenTitle.
///
/// In en, this message translates to:
/// **'Connect over TCP'**
String get tcpScreenTitle;
/// No description provided for @tcpHostLabel.
///
/// In en, this message translates to:
/// **'IP Address'**
String get tcpHostLabel;
/// No description provided for @tcpHostHint.
///
/// In en, this message translates to:
/// **'192.168.40.10'**
String get tcpHostHint;
/// No description provided for @tcpPortLabel.
///
/// In en, this message translates to:
/// **'Port'**
String get tcpPortLabel;
/// No description provided for @tcpPortHint.
///
/// In en, this message translates to:
/// **'5000'**
String get tcpPortHint;
/// No description provided for @tcpStatus_notConnected.
///
/// In en, this message translates to:
/// **'Enter endpoint and connect'**
String get tcpStatus_notConnected;
/// No description provided for @tcpStatus_connectingTo.
///
/// In en, this message translates to:
/// **'Connecting to {endpoint}...'**
String tcpStatus_connectingTo(String endpoint);
/// No description provided for @tcpErrorHostRequired.
///
/// In en, this message translates to:
/// **'IP address is required.'**
String get tcpErrorHostRequired;
/// No description provided for @tcpErrorPortInvalid.
///
/// In en, this message translates to:
/// **'Port must be between 1 and 65535.'**
String get tcpErrorPortInvalid;
/// No description provided for @tcpErrorUnsupported.
///
/// In en, this message translates to:
/// **'TCP transport is not supported on this platform.'**
String get tcpErrorUnsupported;
/// No description provided for @tcpErrorTimedOut.
///
/// In en, this message translates to:
/// **'TCP connection timed out.'**
String get tcpErrorTimedOut;
/// No description provided for @tcpConnectionFailed.
///
/// In en, this message translates to:
/// **'TCP connection failed: {error}'**
String tcpConnectionFailed(String error);
/// No description provided for @usbScreenTitle. /// No description provided for @usbScreenTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -748,6 +826,84 @@ abstract class AppLocalizations {
/// **'Privacy mode disabled'** /// **'Privacy mode disabled'**
String get settings_privacyModeDisabled; String get settings_privacyModeDisabled;
/// No description provided for @settings_privacy.
///
/// In en, this message translates to:
/// **'Privacy Settings'**
String get settings_privacy;
/// No description provided for @settings_privacySubtitle.
///
/// In en, this message translates to:
/// **'Control what information is shared.'**
String get settings_privacySubtitle;
/// No description provided for @settings_privacySettingsDescription.
///
/// In en, this message translates to:
/// **'Choose what information your device shares with others.'**
String get settings_privacySettingsDescription;
/// No description provided for @settings_denyAll.
///
/// In en, this message translates to:
/// **'Deny all'**
String get settings_denyAll;
/// No description provided for @settings_allowByContact.
///
/// In en, this message translates to:
/// **'Allow by contact flags'**
String get settings_allowByContact;
/// No description provided for @settings_allowAll.
///
/// In en, this message translates to:
/// **'Allow all'**
String get settings_allowAll;
/// No description provided for @settings_telemetryBaseMode.
///
/// In en, this message translates to:
/// **'Telemetry Base Mode'**
String get settings_telemetryBaseMode;
/// No description provided for @settings_telemetryLocationMode.
///
/// In en, this message translates to:
/// **'Telemetry Location Mode'**
String get settings_telemetryLocationMode;
/// No description provided for @settings_telemetryEnvironmentMode.
///
/// In en, this message translates to:
/// **'Telemetry Environment Mode'**
String get settings_telemetryEnvironmentMode;
/// No description provided for @settings_advertLocation.
///
/// In en, this message translates to:
/// **'Advert Location'**
String get settings_advertLocation;
/// No description provided for @settings_advertLocationSubtitle.
///
/// In en, this message translates to:
/// **'Include location in advert.'**
String get settings_advertLocationSubtitle;
/// No description provided for @settings_multiAck.
///
/// In en, this message translates to:
/// **'Multi-ACKs: {value}'**
String settings_multiAck(String value);
/// No description provided for @settings_telemetryModeUpdated.
///
/// In en, this message translates to:
/// **'Telemetry mode updated'**
String get settings_telemetryModeUpdated;
/// No description provided for @settings_actions. /// No description provided for @settings_actions.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1636,6 +1792,12 @@ abstract class AppLocalizations {
/// **'Group name is required'** /// **'Group name is required'**
String get contacts_groupNameRequired; String get contacts_groupNameRequired;
/// No description provided for @contacts_groupNameReserved.
///
/// In en, this message translates to:
/// **'This group name is reserved'**
String get contacts_groupNameReserved;
/// No description provided for @contacts_groupAlreadyExists. /// No description provided for @contacts_groupAlreadyExists.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1696,6 +1858,72 @@ abstract class AppLocalizations {
/// **'~ {days} days'** /// **'~ {days} days'**
String contacts_lastSeenDaysAgo(int days); String contacts_lastSeenDaysAgo(int days);
/// No description provided for @contact_info.
///
/// In en, this message translates to:
/// **'Contact Info'**
String get contact_info;
/// No description provided for @contact_settings.
///
/// In en, this message translates to:
/// **'Contact Settings'**
String get contact_settings;
/// No description provided for @contact_telemetry.
///
/// In en, this message translates to:
/// **'Telemetry'**
String get contact_telemetry;
/// No description provided for @contact_lastSeen.
///
/// In en, this message translates to:
/// **'Last seen'**
String get contact_lastSeen;
/// No description provided for @contact_clearChat.
///
/// In en, this message translates to:
/// **'Clear Chat'**
String get contact_clearChat;
/// No description provided for @contact_teleBase.
///
/// In en, this message translates to:
/// **'Telemetry Base'**
String get contact_teleBase;
/// No description provided for @contact_teleBaseSubtitle.
///
/// In en, this message translates to:
/// **'Allow sharing battery level and basic telemetry'**
String get contact_teleBaseSubtitle;
/// No description provided for @contact_teleLoc.
///
/// In en, this message translates to:
/// **'Telemetry Location'**
String get contact_teleLoc;
/// No description provided for @contact_teleLocSubtitle.
///
/// In en, this message translates to:
/// **'Allow sharing location data'**
String get contact_teleLocSubtitle;
/// No description provided for @contact_teleEnv.
///
/// In en, this message translates to:
/// **'Telemetry Environment'**
String get contact_teleEnv;
/// No description provided for @contact_teleEnvSubtitle.
///
/// In en, this message translates to:
/// **'Allow sharing environment sensor data'**
String get contact_teleEnvSubtitle;
/// No description provided for @channels_title. /// No description provided for @channels_title.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2668,6 +2896,12 @@ abstract class AppLocalizations {
/// **'Share marker here'** /// **'Share marker here'**
String get map_shareMarkerHere; String get map_shareMarkerHere;
/// No description provided for @map_setAsMyLocation.
///
/// In en, this message translates to:
/// **'Set as my location'**
String get map_setAsMyLocation;
/// No description provided for @map_pinLabel. /// No description provided for @map_pinLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2788,6 +3022,12 @@ abstract class AppLocalizations {
/// **'Show guessed node locations'** /// **'Show guessed node locations'**
String get map_showGuessedLocations; String get map_showGuessedLocations;
/// No description provided for @map_showDiscoveryContacts.
///
/// In en, this message translates to:
/// **'Show Discovery Contacts'**
String get map_showDiscoveryContacts;
/// No description provided for @map_guessedLocation. /// No description provided for @map_guessedLocation.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+135
View File
@@ -117,6 +117,50 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Свържете се чрез TCP';
@override
String get tcpHostLabel => 'IP адрес';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Пристанище';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Свързване към $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необходим е IP адрес.';
@override
String get tcpErrorPortInvalid => 'Портът трябва да бъде между 1 и 65535.';
@override
String get tcpErrorUnsupported =>
'Транспортът чрез TCP не се поддържа на тази платформа.';
@override
String get tcpErrorTimedOut => 'Връзката TCP изтекла.';
@override
String tcpConnectionFailed(String error) {
return 'Неуспешно е установено TCP връзката: $error';
}
@override @override
String get usbScreenTitle => 'Свържете се чрез USB'; String get usbScreenTitle => 'Свържете се чрез USB';
@@ -354,6 +398,52 @@ class AppLocalizationsBg extends AppLocalizations {
String get settings_privacyModeDisabled => String get settings_privacyModeDisabled =>
'Режим на поверителност е деактивиран'; 'Режим на поверителност е деактивиран';
@override
String get settings_privacy => 'Настройки на поверителността';
@override
String get settings_privacySubtitle =>
'Контролирайте каква информация се споделя.';
@override
String get settings_privacySettingsDescription =>
'Изберете каква информация устройството ви споделя с другите.';
@override
String get settings_denyAll => 'Откажи всичко';
@override
String get settings_allowByContact => 'Позволи по флагове за контакт';
@override
String get settings_allowAll => 'Позволи всичко';
@override
String get settings_telemetryBaseMode => 'Базов режим на телеметрия';
@override
String get settings_telemetryLocationMode =>
'Режим на местоположение на телеметрията';
@override
String get settings_telemetryEnvironmentMode =>
'Режим на средата на телеметрията';
@override
String get settings_advertLocation => 'Място на обявата';
@override
String get settings_advertLocationSubtitle =>
'Включи местоположение в обявата';
@override
String settings_multiAck(String value) {
return 'Мулти-потвърди: $value';
}
@override
String get settings_telemetryModeUpdated => 'Режим на телеметрията е обновен';
@override @override
String get settings_actions => 'Действия'; String get settings_actions => 'Действия';
@@ -858,6 +948,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Името на групата е задължително.'; String get contacts_groupNameRequired => 'Името на групата е задължително.';
@override
String get contacts_groupNameReserved => 'Това име на група е запазено';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Групата \"$name\" вече съществува.'; return 'Групата \"$name\" вече съществува.';
@@ -897,6 +990,42 @@ class AppLocalizationsBg extends AppLocalizations {
return 'Последно видян $days дни преди.'; return 'Последно видян $days дни преди.';
} }
@override
String get contact_info => 'Контактна информация';
@override
String get contact_settings => 'Настройки за контакти';
@override
String get contact_telemetry => 'Телеметрия';
@override
String get contact_lastSeen => 'Последно видян';
@override
String get contact_clearChat => 'Изчисти чата';
@override
String get contact_teleBase => 'Базата данни за телеметрия';
@override
String get contact_teleBaseSubtitle =>
'Позволи споделяне на ниво на батерията и основна телеметрия';
@override
String get contact_teleLoc => 'Местоположение на телеметрията';
@override
String get contact_teleLocSubtitle =>
'Позволи споделяне на данни за местоположение';
@override
String get contact_teleEnv => 'Среда на телеметрия';
@override
String get contact_teleEnvSubtitle =>
'Позволи споделяне на данни от средносферните датчици';
@override @override
String get channels_title => 'Канали'; String get channels_title => 'Канали';
@@ -1467,6 +1596,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Споделете маркер тук'; String get map_shareMarkerHere => 'Споделете маркер тук';
@override
String get map_setAsMyLocation => 'Задайте като моя местоположение';
@override @override
String get map_pinLabel => 'Етикетиране на пин'; String get map_pinLabel => 'Етикетиране на пин';
@@ -1531,6 +1663,9 @@ class AppLocalizationsBg extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Покажете местоположенията на предположените възли.'; 'Покажете местоположенията на предположените възли.';
@override
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
@override @override
String get map_guessedLocation => 'Предполагано местоположение'; String get map_guessedLocation => 'Предполагано местоположение';
+134
View File
@@ -117,6 +117,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Verbinden über TCP';
@override
String get tcpHostLabel => 'IP-Adresse';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected =>
'Geben Sie den Endpunkt ein und verbinden Sie sich.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Verbindung zu $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Eine IP-Adresse ist erforderlich.';
@override
String get tcpErrorPortInvalid =>
'Die Portnummer muss zwischen 1 und 65535 liegen.';
@override
String get tcpErrorUnsupported =>
'Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.';
@override
String get tcpErrorTimedOut => 'Die TCP-Verbindung ist abgelaufen.';
@override
String tcpConnectionFailed(String error) {
return 'Fehler beim TCP-Verbindungsaufbau: $error';
}
@override @override
String get usbScreenTitle => 'Verbinden über USB'; String get usbScreenTitle => 'Verbinden über USB';
@@ -352,6 +398,50 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Datenschutzmodus deaktiviert'; String get settings_privacyModeDisabled => 'Datenschutzmodus deaktiviert';
@override
String get settings_privacy => 'Datenschutzeinstellungen';
@override
String get settings_privacySubtitle =>
'Steuern Sie die Informationen, die freigegeben werden.';
@override
String get settings_privacySettingsDescription =>
'Wählen Sie die Informationen, die Ihr Gerät mit anderen teilt.';
@override
String get settings_denyAll => 'Alle ablehnen';
@override
String get settings_allowByContact => 'Zulassen durch Kontaktflaggen';
@override
String get settings_allowAll => 'Alles zulassen';
@override
String get settings_telemetryBaseMode => 'Telemetrie-Basismodus';
@override
String get settings_telemetryLocationMode => 'Telemetrie-Ortsmodus';
@override
String get settings_telemetryEnvironmentMode => 'Telemetrie-Umgebungsmodus';
@override
String get settings_advertLocation => 'Anzeigenort';
@override
String get settings_advertLocationSubtitle =>
'Ort in der Anzeige einbeziehen';
@override
String settings_multiAck(String value) {
return 'Mehrfach-Bestätigungen: $value';
}
@override
String get settings_telemetryModeUpdated => 'Telemetriemodus aktualisiert';
@override @override
String get settings_actions => 'Aktionen'; String get settings_actions => 'Aktionen';
@@ -856,6 +946,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.'; String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
@override
String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Die Gruppe \"$name\" existiert bereits.'; return 'Die Gruppe \"$name\" existiert bereits.';
@@ -895,6 +988,41 @@ class AppLocalizationsDe extends AppLocalizations {
return '~ $days Tage'; return '~ $days Tage';
} }
@override
String get contact_info => 'Kontaktinformationen';
@override
String get contact_settings => 'Kontakteinstellungen';
@override
String get contact_telemetry => 'Telemetrie';
@override
String get contact_lastSeen => 'Zuletzt gesehen';
@override
String get contact_clearChat => 'Chat löschen';
@override
String get contact_teleBase => 'Telemetriebasis';
@override
String get contact_teleBaseSubtitle =>
'Erlauben des Freigebens des Batteriestands und der grundlegenden Telemetrie';
@override
String get contact_teleLoc => 'Telemetrieort';
@override
String get contact_teleLocSubtitle => 'Teilen von Standortdaten zulassen';
@override
String get contact_teleEnv => 'Telemetrieumgebung';
@override
String get contact_teleEnvSubtitle =>
'Teilen von Umgebungsensordaten zulassen';
@override @override
String get channels_title => 'Kanäle'; String get channels_title => 'Kanäle';
@@ -1467,6 +1595,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.'; String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
@override
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
@override @override
String get map_pinLabel => 'Pin Name'; String get map_pinLabel => 'Pin Name';
@@ -1531,6 +1662,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Zeige die vermuteten Knotenpositionen'; 'Zeige die vermuteten Knotenpositionen';
@override
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
@override @override
String get map_guessedLocation => 'Geschätzter Ort'; String get map_guessedLocation => 'Geschätzter Ort';
+129
View File
@@ -117,6 +117,50 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Connect over TCP';
@override
String get tcpHostLabel => 'IP Address';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Enter endpoint and connect';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connecting to $endpoint...';
}
@override
String get tcpErrorHostRequired => 'IP address is required.';
@override
String get tcpErrorPortInvalid => 'Port must be between 1 and 65535.';
@override
String get tcpErrorUnsupported =>
'TCP transport is not supported on this platform.';
@override
String get tcpErrorTimedOut => 'TCP connection timed out.';
@override
String tcpConnectionFailed(String error) {
return 'TCP connection failed: $error';
}
@override @override
String get usbScreenTitle => 'Connect over USB'; String get usbScreenTitle => 'Connect over USB';
@@ -348,6 +392,48 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Privacy mode disabled'; String get settings_privacyModeDisabled => 'Privacy mode disabled';
@override
String get settings_privacy => 'Privacy Settings';
@override
String get settings_privacySubtitle => 'Control what information is shared.';
@override
String get settings_privacySettingsDescription =>
'Choose what information your device shares with others.';
@override
String get settings_denyAll => 'Deny all';
@override
String get settings_allowByContact => 'Allow by contact flags';
@override
String get settings_allowAll => 'Allow all';
@override
String get settings_telemetryBaseMode => 'Telemetry Base Mode';
@override
String get settings_telemetryLocationMode => 'Telemetry Location Mode';
@override
String get settings_telemetryEnvironmentMode => 'Telemetry Environment Mode';
@override
String get settings_advertLocation => 'Advert Location';
@override
String get settings_advertLocationSubtitle => 'Include location in advert.';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Telemetry mode updated';
@override @override
String get settings_actions => 'Actions'; String get settings_actions => 'Actions';
@@ -845,6 +931,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Group name is required'; String get contacts_groupNameRequired => 'Group name is required';
@override
String get contacts_groupNameReserved => 'This group name is reserved';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Group \"$name\" already exists'; return 'Group \"$name\" already exists';
@@ -883,6 +972,40 @@ class AppLocalizationsEn extends AppLocalizations {
return '~ $days days'; return '~ $days days';
} }
@override
String get contact_info => 'Contact Info';
@override
String get contact_settings => 'Contact Settings';
@override
String get contact_telemetry => 'Telemetry';
@override
String get contact_lastSeen => 'Last seen';
@override
String get contact_clearChat => 'Clear Chat';
@override
String get contact_teleBase => 'Telemetry Base';
@override
String get contact_teleBaseSubtitle =>
'Allow sharing battery level and basic telemetry';
@override
String get contact_teleLoc => 'Telemetry Location';
@override
String get contact_teleLocSubtitle => 'Allow sharing location data';
@override
String get contact_teleEnv => 'Telemetry Environment';
@override
String get contact_teleEnvSubtitle => 'Allow sharing environment sensor data';
@override @override
String get channels_title => 'Channels'; String get channels_title => 'Channels';
@@ -1443,6 +1566,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Share marker here'; String get map_shareMarkerHere => 'Share marker here';
@override
String get map_setAsMyLocation => 'Set as my location';
@override @override
String get map_pinLabel => 'Pin label'; String get map_pinLabel => 'Pin label';
@@ -1506,6 +1632,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Show guessed node locations'; String get map_showGuessedLocations => 'Show guessed node locations';
@override
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
@override @override
String get map_guessedLocation => 'Guessed location'; String get map_guessedLocation => 'Guessed location';
+135
View File
@@ -117,6 +117,50 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Establecer conexión a través de TCP';
@override
String get tcpHostLabel => 'Dirección IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Puerto';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Conectándose a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Se requiere la dirección IP.';
@override
String get tcpErrorPortInvalid => 'El puerto debe estar entre 1 y 65535.';
@override
String get tcpErrorUnsupported =>
'El protocolo de transporte TCP no está soportado en esta plataforma.';
@override
String get tcpErrorTimedOut => 'La conexión TCP ha caducado.';
@override
String tcpConnectionFailed(String error) {
return 'Error en la conexión TCP: $error';
}
@override @override
String get usbScreenTitle => 'Conecte mediante USB'; String get usbScreenTitle => 'Conecte mediante USB';
@@ -352,6 +396,51 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Modo de privacidad desactivado'; String get settings_privacyModeDisabled => 'Modo de privacidad desactivado';
@override
String get settings_privacy => 'Configuración de privacidad';
@override
String get settings_privacySubtitle =>
'Controlar qué información se comparte.';
@override
String get settings_privacySettingsDescription =>
'Elige qué información comparte tu dispositivo con otros.';
@override
String get settings_denyAll => 'Denegar todo';
@override
String get settings_allowByContact => 'Permitir por banderas de contacto';
@override
String get settings_allowAll => 'Permitir todo';
@override
String get settings_telemetryBaseMode => 'Modo base de telemetría';
@override
String get settings_telemetryLocationMode =>
'Modo de ubicación de telemetría';
@override
String get settings_telemetryEnvironmentMode =>
'Modo de entorno de telemetría';
@override
String get settings_advertLocation => 'Ubicación de anuncio';
@override
String get settings_advertLocationSubtitle => 'Incluir ubicación en anuncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Modo de telemetría actualizado';
@override @override
String get settings_actions => 'Acciones'; String get settings_actions => 'Acciones';
@@ -857,6 +946,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio'; String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
@override
String get contacts_groupNameReserved =>
'Este nombre de grupo está reservado';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'El grupo \"$name\" ya existe'; return 'El grupo \"$name\" ya existe';
@@ -896,6 +989,42 @@ class AppLocalizationsEs extends AppLocalizations {
return '~ $days días'; return '~ $days días';
} }
@override
String get contact_info => 'Información de contacto';
@override
String get contact_settings => 'Configuración de contacto';
@override
String get contact_telemetry => 'Telemetría';
@override
String get contact_lastSeen => 'Visto por última vez';
@override
String get contact_clearChat => 'Borrar chat';
@override
String get contact_teleBase => 'Base de Telemetría';
@override
String get contact_teleBaseSubtitle =>
'Permitir el intercambio de nivel de batería y telemetría básica';
@override
String get contact_teleLoc => 'Ubicación de telemetría';
@override
String get contact_teleLocSubtitle =>
'Permitir el intercambio de datos de ubicación';
@override
String get contact_teleEnv => 'Entorno de Telemetría';
@override
String get contact_teleEnvSubtitle =>
'Permitir el intercambio de datos de sensores de entorno';
@override @override
String get channels_title => 'Canales'; String get channels_title => 'Canales';
@@ -1465,6 +1594,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartir marcador aquí'; String get map_shareMarkerHere => 'Compartir marcador aquí';
@override
String get map_setAsMyLocation => 'Establecer mi ubicación';
@override @override
String get map_pinLabel => 'Etiqueta de marcador'; String get map_pinLabel => 'Etiqueta de marcador';
@@ -1529,6 +1661,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Mostrar las ubicaciones estimadas de los nodos.'; 'Mostrar las ubicaciones estimadas de los nodos.';
@override
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
@override @override
String get map_guessedLocation => 'Ubicación estimada'; String get map_guessedLocation => 'Ubicación estimada';
+137
View File
@@ -117,6 +117,52 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Établir une connexion via TCP';
@override
String get tcpHostLabel => 'Adresse IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected =>
'Entrez l\'adresse de destination et connectez-vous.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connexion à $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Une adresse IP est obligatoire.';
@override
String get tcpErrorPortInvalid =>
'La taille du port doit être comprise entre 1 et 65535.';
@override
String get tcpErrorUnsupported =>
'Le protocole TCP n\'est pas pris en charge sur cette plateforme.';
@override
String get tcpErrorTimedOut => 'La connexion TCP a expiré.';
@override
String tcpConnectionFailed(String error) {
return 'Échec de la connexion TCP : $error';
}
@override @override
String get usbScreenTitle => 'Connectez via USB'; String get usbScreenTitle => 'Connectez via USB';
@@ -354,6 +400,52 @@ class AppLocalizationsFr extends AppLocalizations {
String get settings_privacyModeDisabled => String get settings_privacyModeDisabled =>
'Mode de confidentialité désactivé'; 'Mode de confidentialité désactivé';
@override
String get settings_privacy => 'Paramètres de confidentialité';
@override
String get settings_privacySubtitle => 'Contrôlez les informations partagées';
@override
String get settings_privacySettingsDescription =>
'Choisissez les informations que votre appareil partage avec les autres.';
@override
String get settings_denyAll => 'Refuser tout';
@override
String get settings_allowByContact => 'Autoriser par drapeaux de contact';
@override
String get settings_allowAll => 'Autoriser tout';
@override
String get settings_telemetryBaseMode => 'Mode de base Télémétrie';
@override
String get settings_telemetryLocationMode =>
'Mode d\'emplacement de télémétrie';
@override
String get settings_telemetryEnvironmentMode =>
'Mode d\'environnement de télémétrie';
@override
String get settings_advertLocation => 'Emplacement de l\'annonce';
@override
String get settings_advertLocationSubtitle =>
'Inclure l\'emplacement dans l\'annonce';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs : $value';
}
@override
String get settings_telemetryModeUpdated =>
'Le mode télémétrie a été mis à jour';
@override @override
String get settings_actions => 'Actions'; String get settings_actions => 'Actions';
@@ -859,6 +951,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.'; String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
@override
String get contacts_groupNameReserved => 'Ce nom de groupe est réservé';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Le groupe \"$name\" existe déjà.'; return 'Le groupe \"$name\" existe déjà.';
@@ -898,6 +993,42 @@ class AppLocalizationsFr extends AppLocalizations {
return '~ $days jours'; return '~ $days jours';
} }
@override
String get contact_info => 'Informations de contact';
@override
String get contact_settings => 'Paramètres de contact';
@override
String get contact_telemetry => 'Télémétrie';
@override
String get contact_lastSeen => 'Dernière fois vu';
@override
String get contact_clearChat => 'Effacer la conversation';
@override
String get contact_teleBase => 'Base de télémétrie';
@override
String get contact_teleBaseSubtitle =>
'Autoriser le partage du niveau de batterie et de la télémétrie de base';
@override
String get contact_teleLoc => 'Emplacement de télémétrie';
@override
String get contact_teleLocSubtitle =>
'Autoriser le partage des données de localisation';
@override
String get contact_teleEnv => 'Environnement Télémétrie';
@override
String get contact_teleEnvSubtitle =>
'Autoriser le partage des données des capteurs d\'environnement';
@override @override
String get channels_title => 'Canaux'; String get channels_title => 'Canaux';
@@ -1472,6 +1603,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Partager le marqueur ici'; String get map_shareMarkerHere => 'Partager le marqueur ici';
@override
String get map_setAsMyLocation => 'Définir comme ma localisation';
@override @override
String get map_pinLabel => 'Étiquete de repin'; String get map_pinLabel => 'Étiquete de repin';
@@ -1536,6 +1670,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Afficher les emplacements des nœuds estimés'; 'Afficher les emplacements des nœuds estimés';
@override
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
@override @override
String get map_guessedLocation => 'Lieu deviné'; String get map_guessedLocation => 'Lieu deviné';
+136
View File
@@ -117,6 +117,51 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Stabilire una connessione tramite TCP';
@override
String get tcpHostLabel => 'Indirizzo IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Porta';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connessione a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'È necessario fornire un indirizzo IP.';
@override
String get tcpErrorPortInvalid =>
'La dimensione della porta deve essere compresa tra 1 e 65535.';
@override
String get tcpErrorUnsupported =>
'Il protocollo TCP non è supportato su questa piattaforma.';
@override
String get tcpErrorTimedOut => 'La connessione TCP è scaduta.';
@override
String tcpConnectionFailed(String error) {
return 'Impossibile stabilire la connessione TCP: $error';
}
@override @override
String get usbScreenTitle => 'Connessione tramite USB'; String get usbScreenTitle => 'Connessione tramite USB';
@@ -353,6 +398,52 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Modalità privacy disabilitata'; String get settings_privacyModeDisabled => 'Modalità privacy disabilitata';
@override
String get settings_privacy => 'Impostazioni sulla privacy';
@override
String get settings_privacySubtitle =>
'Controlla le informazioni che vengono condivise.';
@override
String get settings_privacySettingsDescription =>
'Scegli le informazioni che il tuo dispositivo condivide con gli altri.';
@override
String get settings_denyAll => 'Negare tutto';
@override
String get settings_allowByContact => 'Consenti in base ai flag di contatto';
@override
String get settings_allowAll => 'Consenti tutto';
@override
String get settings_telemetryBaseMode => 'Modalità di base di telemetria';
@override
String get settings_telemetryLocationMode =>
'Modalità di posizionamento telemetrico';
@override
String get settings_telemetryEnvironmentMode =>
'Modalità di ambiente di telemetria';
@override
String get settings_advertLocation => 'Posizione dell\'annuncio';
@override
String get settings_advertLocationSubtitle =>
'Includi la posizione nell\'annuncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Modalità telemetria aggiornata';
@override @override
String get settings_actions => 'Azioni'; String get settings_actions => 'Azioni';
@@ -856,6 +947,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.'; String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
@override
String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Il gruppo \"$name\" esiste già.'; return 'Il gruppo \"$name\" esiste già.';
@@ -895,6 +989,42 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Ultimo visto $days giorni fa'; return 'Ultimo visto $days giorni fa';
} }
@override
String get contact_info => 'Informazioni di Contatto';
@override
String get contact_settings => 'Impostazioni di contatto';
@override
String get contact_telemetry => 'Telemetria';
@override
String get contact_lastSeen => 'Ultimo accesso';
@override
String get contact_clearChat => 'Cancella chat';
@override
String get contact_teleBase => 'Base di telemetria';
@override
String get contact_teleBaseSubtitle =>
'Consenti la condivisione del livello della batteria e della telemetria di base';
@override
String get contact_teleLoc => 'Posizione telemetria';
@override
String get contact_teleLocSubtitle =>
'Consenti la condivisione dei dati di posizione';
@override
String get contact_teleEnv => 'Ambiente di telemetria';
@override
String get contact_teleEnvSubtitle =>
'Consenti la condivisione dei dati del sensore ambientale';
@override @override
String get channels_title => 'Canali'; String get channels_title => 'Canali';
@@ -1465,6 +1595,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Condividi marcatore qui'; String get map_shareMarkerHere => 'Condividi marcatore qui';
@override
String get map_setAsMyLocation => 'Imposta come la mia posizione';
@override @override
String get map_pinLabel => 'Etichetta PIN'; String get map_pinLabel => 'Etichetta PIN';
@@ -1528,6 +1661,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi'; String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
@override
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
@override @override
String get map_guessedLocation => 'Località indovinata'; String get map_guessedLocation => 'Località indovinata';
+132
View File
@@ -117,6 +117,51 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Verbind via TCP';
@override
String get tcpHostLabel => 'IP-adres';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Poort';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Verbinding maken met $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Een IP-adres is vereist.';
@override
String get tcpErrorPortInvalid =>
'De poortwaarde moet tussen 1 en 65535 liggen.';
@override
String get tcpErrorUnsupported =>
'TCP-transport wordt niet ondersteund op deze platform.';
@override
String get tcpErrorTimedOut => 'De TCP-verbinding is verlopen.';
@override
String tcpConnectionFailed(String error) {
return 'Verbinding met TCP mislukt: $error';
}
@override @override
String get usbScreenTitle => 'Verbind via USB'; String get usbScreenTitle => 'Verbind via USB';
@@ -350,6 +395,50 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Privacy modus is uitgeschakeld'; String get settings_privacyModeDisabled => 'Privacy modus is uitgeschakeld';
@override
String get settings_privacy => 'Privacyinstellingen';
@override
String get settings_privacySubtitle =>
'Beheer welke informatie wordt gedeeld';
@override
String get settings_privacySettingsDescription =>
'Kies welke informatie uw apparaat deelt met anderen';
@override
String get settings_denyAll => 'Weiger alles';
@override
String get settings_allowByContact => 'Toestaan op basis van contactvlaggen';
@override
String get settings_allowAll => 'Alles toestaan';
@override
String get settings_telemetryBaseMode => 'Telemetrie-basismodus';
@override
String get settings_telemetryLocationMode => 'Telemetrie-locatiemodus';
@override
String get settings_telemetryEnvironmentMode => 'Telemetrie-omgevingsmodus';
@override
String get settings_advertLocation => 'Advertentielocatie';
@override
String get settings_advertLocationSubtitle =>
'Locatie opnemen in advertentie';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Telemetrie-modus bijgewerkt';
@override @override
String get settings_actions => 'Acties'; String get settings_actions => 'Acties';
@@ -850,6 +939,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'De groepnaam is verplicht.'; String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
@override
String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'De groep \"$name\" bestaat al.'; return 'De groep \"$name\" bestaat al.';
@@ -889,6 +981,40 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Laast gezien $days dagen geleden'; return 'Laast gezien $days dagen geleden';
} }
@override
String get contact_info => 'Contactinformatie';
@override
String get contact_settings => 'Contactinstellingen';
@override
String get contact_telemetry => 'Telemetrie';
@override
String get contact_lastSeen => 'Laatst gezien';
@override
String get contact_clearChat => 'Chat leegmaken';
@override
String get contact_teleBase => 'Telemetrie_basis';
@override
String get contact_teleBaseSubtitle =>
'Sta delen van batterij niveau en basis telemetrie toe';
@override
String get contact_teleLoc => 'Telemetrielocatie';
@override
String get contact_teleLocSubtitle => 'Locatiegegevens delen toestaan';
@override
String get contact_teleEnv => 'Telemetrieomgeving';
@override
String get contact_teleEnvSubtitle => 'Delen van omgevingsensordata toestaan';
@override @override
String get channels_title => 'Kanaal'; String get channels_title => 'Kanaal';
@@ -1457,6 +1583,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Deel marker hier'; String get map_shareMarkerHere => 'Deel marker hier';
@override
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
@override @override
String get map_pinLabel => 'Label vastzetten'; String get map_pinLabel => 'Label vastzetten';
@@ -1521,6 +1650,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Toon de voorspelde locaties van de knopen'; 'Toon de voorspelde locaties van de knopen';
@override
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
@override @override
String get map_guessedLocation => 'Geroerde locatie'; String get map_guessedLocation => 'Geroerde locatie';
+137
View File
@@ -117,6 +117,52 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Połącz się za pomocą protokołu TCP';
@override
String get tcpHostLabel => 'Adres IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Połączenie z $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Wymagana jest adresa IP.';
@override
String get tcpErrorPortInvalid =>
'Numer portu musi mieścić się w zakresie od 1 do 65535.';
@override
String get tcpErrorUnsupported =>
'Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.';
@override
String get tcpErrorTimedOut =>
'Połączenie TCP zakończyło się bez powodzenia.';
@override
String tcpConnectionFailed(String error) {
return 'Błąd połączenia TCP: $error';
}
@override @override
String get usbScreenTitle => 'Połącz przez USB'; String get usbScreenTitle => 'Połącz przez USB';
@@ -355,6 +401,52 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Tryb prywatności wyłączony'; String get settings_privacyModeDisabled => 'Tryb prywatności wyłączony';
@override
String get settings_privacy => 'Ustawienia prywatności';
@override
String get settings_privacySubtitle =>
'Kontroluj jakie informacje są udostępniane.';
@override
String get settings_privacySettingsDescription =>
'Wybierz jakie informacje urządzenie udostępni innym.';
@override
String get settings_denyAll => 'Odmów wszystkim';
@override
String get settings_allowByContact => 'Zezwalaj według flag kontaktowych';
@override
String get settings_allowAll => 'Zezwalaj na wszystko';
@override
String get settings_telemetryBaseMode => 'Tryb podstawowy telemetrii';
@override
String get settings_telemetryLocationMode => 'Tryb położenia telemetrycznego';
@override
String get settings_telemetryEnvironmentMode =>
'Tryb środowiska telemetrycznego';
@override
String get settings_advertLocation => 'Lokalizacja reklamowa';
@override
String get settings_advertLocationSubtitle =>
'Uwzględnij lokalizację w ogłoszeniu';
@override
String settings_multiAck(String value) {
return 'Wiele potwierdzeń: $value';
}
@override
String get settings_telemetryModeUpdated =>
'Tryb telemetryczny zaktualizowany';
@override @override
String get settings_actions => 'Działania'; String get settings_actions => 'Działania';
@@ -858,6 +950,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana'; String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana';
@override
String get contacts_groupNameReserved => 'Ta nazwa grupy jest zastrzeżona';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Grupa \"$name\" już istnieje'; return 'Grupa \"$name\" już istnieje';
@@ -897,6 +992,42 @@ class AppLocalizationsPl extends AppLocalizations {
return 'Ostatnie połączenie $days dni temu'; return 'Ostatnie połączenie $days dni temu';
} }
@override
String get contact_info => 'Informacje kontaktowe';
@override
String get contact_settings => 'Ustawienia kontaktowe';
@override
String get contact_telemetry => 'Telemetryka';
@override
String get contact_lastSeen => 'Ostatnio widziany';
@override
String get contact_clearChat => 'Wyczyść czat';
@override
String get contact_teleBase => 'Baza telemetryczna';
@override
String get contact_teleBaseSubtitle =>
'Pozwól na udostępnianie poziomu naładowania baterii i podstawowych danych telemetrycznych';
@override
String get contact_teleLoc => 'Lokalizacja telemetryczna';
@override
String get contact_teleLocSubtitle =>
'Zezwalaj na udostępnianie danych lokalizacji';
@override
String get contact_teleEnv => 'Środowisko telemetryczne';
@override
String get contact_teleEnvSubtitle =>
'Zezwalaj na udostępnianie danych czujników środowiskowych';
@override @override
String get channels_title => 'Kanały'; String get channels_title => 'Kanały';
@@ -1466,6 +1597,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj'; String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
@override
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
@override @override
String get map_pinLabel => 'Oznacz etykietę'; String get map_pinLabel => 'Oznacz etykietę';
@@ -1530,6 +1664,9 @@ class AppLocalizationsPl extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Wyświetl lokalizacje zgadanych węzłów'; 'Wyświetl lokalizacje zgadanych węzłów';
@override
String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania';
@override @override
String get map_guessedLocation => 'Wydana lokalizacja'; String get map_guessedLocation => 'Wydana lokalizacja';
+135
View File
@@ -117,6 +117,51 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Estabelecer conexão via TCP';
@override
String get tcpHostLabel => 'Endereço IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Porta';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Conectando a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'É necessário fornecer um endereço IP.';
@override
String get tcpErrorPortInvalid =>
'O valor do porto deve estar entre 1 e 65535.';
@override
String get tcpErrorUnsupported =>
'O protocolo TCP não é suportado nesta plataforma.';
@override
String get tcpErrorTimedOut => 'A conexão TCP expirou.';
@override
String tcpConnectionFailed(String error) {
return 'Falha na conexão TCP: $error';
}
@override @override
String get usbScreenTitle => 'Conecte via USB'; String get usbScreenTitle => 'Conecte via USB';
@@ -353,6 +398,51 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Modo de privacidade desativado'; String get settings_privacyModeDisabled => 'Modo de privacidade desativado';
@override
String get settings_privacy => 'Configurações de Privacidade';
@override
String get settings_privacySubtitle => 'Controle o que é compartilhado.';
@override
String get settings_privacySettingsDescription =>
'Escolha quais informações o seu dispositivo compartilha com os outros.';
@override
String get settings_denyAll => 'Negar todos';
@override
String get settings_allowByContact => 'Permitir por bandeiras de contato';
@override
String get settings_allowAll => 'Permitir todos';
@override
String get settings_telemetryBaseMode => 'Modo Base de Telemetria';
@override
String get settings_telemetryLocationMode =>
'Modo de Localização de Telemetria';
@override
String get settings_telemetryEnvironmentMode =>
'Modo de Ambiente de Telemetria';
@override
String get settings_advertLocation => 'Localização do Anúncio';
@override
String get settings_advertLocationSubtitle =>
'Incluir localização no anúncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Modo de telemetria atualizado';
@override @override
String get settings_actions => 'Ações'; String get settings_actions => 'Ações';
@@ -858,6 +948,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.'; String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
@override
String get contacts_groupNameReserved => 'Este nome de grupo está reservado';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'O grupo \"$name\" já existe'; return 'O grupo \"$name\" já existe';
@@ -897,6 +990,42 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Última vez visto $days dias atrás'; return 'Última vez visto $days dias atrás';
} }
@override
String get contact_info => 'Informações de Contato';
@override
String get contact_settings => 'Configurações de Contato';
@override
String get contact_telemetry => 'Telemetria';
@override
String get contact_lastSeen => 'Visto pela última vez';
@override
String get contact_clearChat => 'Limpar Chat';
@override
String get contact_teleBase => 'Base de Telemetria';
@override
String get contact_teleBaseSubtitle =>
'Permitir compartilhamento do nível da bateria e telemetria básica';
@override
String get contact_teleLoc => 'Localização de Telemetria';
@override
String get contact_teleLocSubtitle =>
'Permitir compartilhamento de dados de localização';
@override
String get contact_teleEnv => 'Ambiente de Telemetria';
@override
String get contact_teleEnvSubtitle =>
'Permitir compartilhamento de dados do sensor de ambiente';
@override @override
String get channels_title => 'Canais'; String get channels_title => 'Canais';
@@ -1466,6 +1595,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartilhar marcador aqui'; String get map_shareMarkerHere => 'Compartilhar marcador aqui';
@override
String get map_setAsMyLocation => 'Defina minha localização';
@override @override
String get map_pinLabel => 'Rótulo de marcador'; String get map_pinLabel => 'Rótulo de marcador';
@@ -1530,6 +1662,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Mostrar as localizações dos nós estimados'; 'Mostrar as localizações dos nós estimados';
@override
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
@override @override
String get map_guessedLocation => 'Localização estimada'; String get map_guessedLocation => 'Localização estimada';
+135
View File
@@ -117,6 +117,51 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Установить соединение по протоколу TCP';
@override
String get tcpHostLabel => 'IP-адрес';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Порт';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Введите адрес и подключитесь.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Подключение к $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необходимо указать IP-адрес.';
@override
String get tcpErrorPortInvalid =>
'Порт должен находиться в диапазоне от 1 до 65535.';
@override
String get tcpErrorUnsupported =>
'Протокол TCP не поддерживается на этой платформе.';
@override
String get tcpErrorTimedOut => 'Соединение TCP не удалось установить.';
@override
String tcpConnectionFailed(String error) {
return 'Не удалось установить соединение TCP: $error';
}
@override @override
String get usbScreenTitle => 'Подключение через USB'; String get usbScreenTitle => 'Подключение через USB';
@@ -353,6 +398,51 @@ class AppLocalizationsRu extends AppLocalizations {
String get settings_privacyModeDisabled => String get settings_privacyModeDisabled =>
'Режим конфиденциальности выключен'; 'Режим конфиденциальности выключен';
@override
String get settings_privacy => 'Настройки конфиденциальности';
@override
String get settings_privacySubtitle =>
'Контролируйте, какую информацию делиться.';
@override
String get settings_privacySettingsDescription =>
'Выберите, какую информацию ваше устройство будет делиться с другими.';
@override
String get settings_denyAll => 'Отклонить все';
@override
String get settings_allowByContact => 'Разрешить по флагам контактов';
@override
String get settings_allowAll => 'Разрешить все';
@override
String get settings_telemetryBaseMode => 'Базовый режим телеметрии';
@override
String get settings_telemetryLocationMode =>
'Режим местоположения телеметрии';
@override
String get settings_telemetryEnvironmentMode => 'Режим среды телеметрии';
@override
String get settings_advertLocation => 'Местоположение рекламы';
@override
String get settings_advertLocationSubtitle =>
'Включить местоположение в объявление';
@override
String settings_multiAck(String value) {
return 'Мульти-ACK: $value';
}
@override
String get settings_telemetryModeUpdated => 'Режим телеметрии обновлен';
@override @override
String get settings_actions => 'Действия'; String get settings_actions => 'Действия';
@@ -857,6 +947,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Имя группы обязательно'; String get contacts_groupNameRequired => 'Имя группы обязательно';
@override
String get contacts_groupNameReserved => 'Это имя группы зарезервировано';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Группа \"$name\" уже существует'; return 'Группа \"$name\" уже существует';
@@ -896,6 +989,42 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Видели $days дн. назад'; return 'Видели $days дн. назад';
} }
@override
String get contact_info => 'Контактная информация';
@override
String get contact_settings => 'Настройки контактов';
@override
String get contact_telemetry => 'Телеметрия';
@override
String get contact_lastSeen => 'Последний раз видели';
@override
String get contact_clearChat => 'Очистить чат';
@override
String get contact_teleBase => 'База телеметрии';
@override
String get contact_teleBaseSubtitle =>
'Разрешить обмен уровнем заряда батареи и базовой телеметрией';
@override
String get contact_teleLoc => 'Местоположение телеметрии';
@override
String get contact_teleLocSubtitle =>
'Разрешить обмен данными о местоположении';
@override
String get contact_teleEnv => 'Среда телеметрии';
@override
String get contact_teleEnvSubtitle =>
'Разрешить обмен данными датчиков окружающей среды';
@override @override
String get channels_title => 'Каналы'; String get channels_title => 'Каналы';
@@ -1468,6 +1597,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поделиться меткой здесь'; String get map_shareMarkerHere => 'Поделиться меткой здесь';
@override
String get map_setAsMyLocation => 'Установить мое местоположение';
@override @override
String get map_pinLabel => 'Метка'; String get map_pinLabel => 'Метка';
@@ -1532,6 +1664,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Отобразить предполагаемые места расположения узлов'; 'Отобразить предполагаемые места расположения узлов';
@override
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
@override @override
String get map_guessedLocation => 'Угаданное место'; String get map_guessedLocation => 'Угаданное место';
+131
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Spojte sa pomocou protokolu TCP';
@override
String get tcpHostLabel => 'IP adresa';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Pripojenie k $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Je potrebné zadať IP adresu.';
@override
String get tcpErrorPortInvalid => 'Číslo portu musí byť medzi 1 a 65535.';
@override
String get tcpErrorUnsupported =>
'Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.';
@override
String get tcpErrorTimedOut => 'Pripojenie TCP vypršalo.';
@override
String tcpConnectionFailed(String error) {
return 'Neúspešné vytvorenie TCP spojenia: $error';
}
@override @override
String get usbScreenTitle => 'Pripojte cez USB'; String get usbScreenTitle => 'Pripojte cez USB';
@@ -351,6 +395,49 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Ochranný režim je vypnutý'; String get settings_privacyModeDisabled => 'Ochranný režim je vypnutý';
@override
String get settings_privacy => 'Nastavenia súkromia';
@override
String get settings_privacySubtitle => 'Ovládni, aké informácie sa zdieľajú.';
@override
String get settings_privacySettingsDescription =>
'Vyberte, ktoré informácie váš zariadenie zdieľa s ostatnými.';
@override
String get settings_denyAll => 'Zamietnuť všetko';
@override
String get settings_allowByContact => 'Povoliť podľa kontaktových vlajok';
@override
String get settings_allowAll => 'Povoliť všetko';
@override
String get settings_telemetryBaseMode => 'Základný režim telemetrie';
@override
String get settings_telemetryLocationMode => 'Režim umiestnenia telemetrie';
@override
String get settings_telemetryEnvironmentMode => 'Režim prostredia telemetrie';
@override
String get settings_advertLocation => 'Umiestnenie inzerátu';
@override
String get settings_advertLocationSubtitle => 'Zahrnúť polohu do inzerátu';
@override
String settings_multiAck(String value) {
return 'Viaceré ACK: $value';
}
@override
String get settings_telemetryModeUpdated =>
'Režim telemetrie bol aktualizovaný';
@override @override
String get settings_actions => 'Možné akcie'; String get settings_actions => 'Možné akcie';
@@ -850,6 +937,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Skupina musí mať názov.'; String get contacts_groupNameRequired => 'Skupina musí mať názov.';
@override
String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Skupina \"$name\" už existuje'; return 'Skupina \"$name\" už existuje';
@@ -891,6 +981,41 @@ class AppLocalizationsSk extends AppLocalizations {
return 'Posledné zobrazenie $days dní dozadu'; return 'Posledné zobrazenie $days dní dozadu';
} }
@override
String get contact_info => 'Kontaktné informácie';
@override
String get contact_settings => 'Nastavenia kontaktov';
@override
String get contact_telemetry => 'Telemetria';
@override
String get contact_lastSeen => 'Naposledy videný';
@override
String get contact_clearChat => 'Vymazať chat';
@override
String get contact_teleBase => 'Báza telemetrie';
@override
String get contact_teleBaseSubtitle =>
'Povoliť zdieľanie úrovne batérie a základnej telemetrie';
@override
String get contact_teleLoc => 'Lokácia telemetrie';
@override
String get contact_teleLocSubtitle => 'Povoliť zdieľanie údajov o lokalite';
@override
String get contact_teleEnv => 'Prostredie telemetrie';
@override
String get contact_teleEnvSubtitle =>
'Povoliť zdieľanie údajov senzorov prostredia';
@override @override
String get channels_title => 'Kanály'; String get channels_title => 'Kanály';
@@ -1460,6 +1585,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Zdieľte značku tu'; String get map_shareMarkerHere => 'Zdieľte značku tu';
@override
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
@override @override
String get map_pinLabel => 'Označka upozornenia'; String get map_pinLabel => 'Označka upozornenia';
@@ -1524,6 +1652,9 @@ class AppLocalizationsSk extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Zobraziť umiestnenia odhadnutých uzlov'; 'Zobraziť umiestnenia odhadnutých uzlov';
@override
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
@override @override
String get map_guessedLocation => 'Odhadnutá lokalita'; String get map_guessedLocation => 'Odhadnutá lokalita';
+132
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Komunicirajte preko protokola TCP';
@override
String get tcpHostLabel => 'IP naslov';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Vrata';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Povezava z $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Potrebna je IP-naslov.';
@override
String get tcpErrorPortInvalid => 'Port mora biti med 1 in 65535.';
@override
String get tcpErrorUnsupported =>
'Transport preko protokola TCP ni podprt na tej platformi.';
@override
String get tcpErrorTimedOut => 'Povezava TCP je presegla časovno obdobje.';
@override
String tcpConnectionFailed(String error) {
return 'Napaka pri povezavi TCP: $error';
}
@override @override
String get usbScreenTitle => 'Povežite preko USB'; String get usbScreenTitle => 'Povežite preko USB';
@@ -349,6 +393,50 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Privatni način je onemogočen.'; String get settings_privacyModeDisabled => 'Privatni način je onemogočen.';
@override
String get settings_privacy => 'Nastavitve zasebnosti';
@override
String get settings_privacySubtitle =>
'Kontrolirajte, katere informacije so deljene.';
@override
String get settings_privacySettingsDescription =>
'Izberite, katere informacije vaš naprava deli z drugimi.';
@override
String get settings_denyAll => 'Zavrniti vse';
@override
String get settings_allowByContact => 'Dovoli po kontaktnih zastavah';
@override
String get settings_allowAll => 'Dovoli vse';
@override
String get settings_telemetryBaseMode => 'Osnovni način telemetrije';
@override
String get settings_telemetryLocationMode => 'Način delovanja telemetrije';
@override
String get settings_telemetryEnvironmentMode =>
'Način delovanja okolja telemetrije';
@override
String get settings_advertLocation => 'Lokacija oglasa';
@override
String get settings_advertLocationSubtitle => 'Vključi lokacijo v oglas.';
@override
String settings_multiAck(String value) {
return 'Večkratni potrditvi: $value';
}
@override
String get settings_telemetryModeUpdated => 'Način telemetrije posodobljen';
@override @override
String get settings_actions => 'Akcije'; String get settings_actions => 'Akcije';
@@ -848,6 +936,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Ime skupine je obvezno.'; String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
@override
String get contacts_groupNameReserved => 'To ime skupine je rezervirano';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Skupina \"$name\" že obstaja'; return 'Skupina \"$name\" že obstaja';
@@ -887,6 +978,41 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Zadnjič viden pred $days dnem'; return 'Zadnjič viden pred $days dnem';
} }
@override
String get contact_info => 'Kontaktni podatki';
@override
String get contact_settings => 'Nastavitve stika';
@override
String get contact_telemetry => 'Telemetrija';
@override
String get contact_lastSeen => 'Zadnjič videno';
@override
String get contact_clearChat => 'Počisti klepet';
@override
String get contact_teleBase => 'Baza telemetrije';
@override
String get contact_teleBaseSubtitle =>
'Dovoli deljenje stanja baterije in osnovne telemetrije';
@override
String get contact_teleLoc => 'Lokacija telemetrije';
@override
String get contact_teleLocSubtitle => 'Dovoli deljenje podatkov o lokaciji';
@override
String get contact_teleEnv => 'Okolje telemetrije';
@override
String get contact_teleEnvSubtitle =>
'Dovoli deljenje podatkov okoljskih senzorjev';
@override @override
String get channels_title => 'Kanali'; String get channels_title => 'Kanali';
@@ -1454,6 +1580,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Delite točke tukaj.'; String get map_shareMarkerHere => 'Delite točke tukaj.';
@override
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
@override @override
String get map_pinLabel => 'Oznaka za pritrditev'; String get map_pinLabel => 'Oznaka za pritrditev';
@@ -1517,6 +1646,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.'; String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
@override
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
@override @override
String get map_guessedLocation => 'Predpostavljena lokacija'; String get map_guessedLocation => 'Predpostavljena lokacija';
+130
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Anslut via TCP';
@override
String get tcpHostLabel => 'IP-adress';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Ange slutpunkt och anslut';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Anslutning till $endpoint...';
}
@override
String get tcpErrorHostRequired => 'IP-adress krävs.';
@override
String get tcpErrorPortInvalid => 'Porten måste vara mellan 1 och 65535.';
@override
String get tcpErrorUnsupported =>
'TCP-transport fungerar inte på denna plattform.';
@override
String get tcpErrorTimedOut => 'TCP-anslutningen har tidsut gått.';
@override
String tcpConnectionFailed(String error) {
return 'Fel vid TCP-anslutning: $error';
}
@override @override
String get usbScreenTitle => 'Anslut via USB'; String get usbScreenTitle => 'Anslut via USB';
@@ -348,6 +392,49 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Privatläge är avstängt'; String get settings_privacyModeDisabled => 'Privatläge är avstängt';
@override
String get settings_privacy => 'Inställningar för sekretess';
@override
String get settings_privacySubtitle =>
'Kontrollera vilken information som delas.';
@override
String get settings_privacySettingsDescription =>
'Välj vilken information din enhet delar med andra.';
@override
String get settings_denyAll => 'Neka alla';
@override
String get settings_allowByContact => 'Tillåt via kontaktflaggor';
@override
String get settings_allowAll => 'Tillåt alla';
@override
String get settings_telemetryBaseMode => 'Telemetribasläge';
@override
String get settings_telemetryLocationMode => 'Telemetritillstånd för plats';
@override
String get settings_telemetryEnvironmentMode => 'Telemetri miljöläge';
@override
String get settings_advertLocation => 'Annonsplacering';
@override
String get settings_advertLocationSubtitle => 'Inkludera plats i annonsen';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Telemetri-läge uppdaterat';
@override @override
String get settings_actions => 'Åtgärder'; String get settings_actions => 'Åtgärder';
@@ -844,6 +931,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt'; String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
@override
String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Gruppen \"$name\" finns redan.'; return 'Gruppen \"$name\" finns redan.';
@@ -883,6 +973,40 @@ class AppLocalizationsSv extends AppLocalizations {
return 'Senast synlig $days dagar sedan'; return 'Senast synlig $days dagar sedan';
} }
@override
String get contact_info => 'Kontaktinformation';
@override
String get contact_settings => 'Kontaktinställningar';
@override
String get contact_telemetry => 'Telemetri';
@override
String get contact_lastSeen => 'Senast sedd';
@override
String get contact_clearChat => 'Rensa Chatt';
@override
String get contact_teleBase => 'Telemetribas';
@override
String get contact_teleBaseSubtitle =>
'Tillåt delning av batterinivå och grundläggande telemetri';
@override
String get contact_teleLoc => 'Telemetridata plats';
@override
String get contact_teleLocSubtitle => 'Tillåt delning av platsdata';
@override
String get contact_teleEnv => 'Telemetri Miljö';
@override
String get contact_teleEnvSubtitle => 'Tillåt delning av miljösensordata';
@override @override
String get channels_title => 'Kanaler'; String get channels_title => 'Kanaler';
@@ -1450,6 +1574,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Dela markeringen här'; String get map_shareMarkerHere => 'Dela markeringen här';
@override
String get map_setAsMyLocation => 'Ange som min plats';
@override @override
String get map_pinLabel => 'Fästetikett'; String get map_pinLabel => 'Fästetikett';
@@ -1514,6 +1641,9 @@ class AppLocalizationsSv extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Visa upp de antagna nodernas placeringar'; 'Visa upp de antagna nodernas placeringar';
@override
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
@override @override
String get map_guessedLocation => 'Gissad plats'; String get map_guessedLocation => 'Gissad plats';
+134
View File
@@ -117,6 +117,51 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'З\'єднатися через протокол TCP';
@override
String get tcpHostLabel => 'IP-адреса';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Порт';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Підключення до $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необхідно вказати IP-адресу.';
@override
String get tcpErrorPortInvalid => 'Порт повинен бути в межах від 1 до 65535.';
@override
String get tcpErrorUnsupported =>
'Транспорт TCP не підтримується на цій платформі.';
@override
String get tcpErrorTimedOut =>
'З\'єднання TCP завершилося через закінчення часу очікування.';
@override
String tcpConnectionFailed(String error) {
return 'Не вдалося встановити з\'єднання TCP: $error';
}
@override @override
String get usbScreenTitle => 'Підключити через USB'; String get usbScreenTitle => 'Підключити через USB';
@@ -350,6 +395,50 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => 'Режим приватності вимкнено'; String get settings_privacyModeDisabled => 'Режим приватності вимкнено';
@override
String get settings_privacy => 'Налаштування приватності';
@override
String get settings_privacySubtitle =>
'Керуйте інформацією, яку буде спільно використовуватися';
@override
String get settings_privacySettingsDescription =>
'Виберіть, яку інформацію ваш пристрій буде передавати іншим.';
@override
String get settings_denyAll => 'Відхилити все';
@override
String get settings_allowByContact => 'Дозволити за контактними прапорцями';
@override
String get settings_allowAll => 'Дозволити все';
@override
String get settings_telemetryBaseMode => 'Режим базової телеметрії';
@override
String get settings_telemetryLocationMode => 'Режим місця телеметрії';
@override
String get settings_telemetryEnvironmentMode => 'Режим середовища телеметрії';
@override
String get settings_advertLocation => 'Розміщення реклами';
@override
String get settings_advertLocationSubtitle =>
'Включити місце розташування в оголошення';
@override
String settings_multiAck(String value) {
return 'Багатократне підтвердження: $value';
}
@override
String get settings_telemetryModeUpdated => 'Режим телеметрії оновлено';
@override @override
String get settings_actions => 'Дії'; String get settings_actions => 'Дії';
@@ -853,6 +942,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.'; String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
@override
String get contacts_groupNameReserved => 'Ця назва групи зарезервована';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Група «$name» вже існує.'; return 'Група «$name» вже існує.';
@@ -892,6 +984,42 @@ class AppLocalizationsUk extends AppLocalizations {
return 'В мережі $days дн. тому'; return 'В мережі $days дн. тому';
} }
@override
String get contact_info => 'Контактна інформація';
@override
String get contact_settings => 'Налаштування контактів';
@override
String get contact_telemetry => 'Телеметрія';
@override
String get contact_lastSeen => 'Останній раз бачили';
@override
String get contact_clearChat => 'Очистити чат';
@override
String get contact_teleBase => 'Базовий телебачення';
@override
String get contact_teleBaseSubtitle =>
'Дозволити спільний доступ до рівня заряду батареї та базової телеметрії';
@override
String get contact_teleLoc => 'Розташування телеметрії';
@override
String get contact_teleLocSubtitle =>
'Дозволити спільне використання даних про місцеположення';
@override
String get contact_teleEnv => 'Середовище телеметрії';
@override
String get contact_teleEnvSubtitle =>
'Дозволити спільний доступ до даних датчиків середовища';
@override @override
String get channels_title => 'Канали'; String get channels_title => 'Канали';
@@ -1465,6 +1593,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поділитися маркером тут'; String get map_shareMarkerHere => 'Поділитися маркером тут';
@override
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
@override @override
String get map_pinLabel => 'Мітка піна'; String get map_pinLabel => 'Мітка піна';
@@ -1529,6 +1660,9 @@ class AppLocalizationsUk extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Показати місцезнаходження передбачених вузлів'; 'Показати місцезнаходження передбачених вузлів';
@override
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
@override @override
String get map_guessedLocation => 'Визначено місцезнаходження'; String get map_guessedLocation => 'Визначено місцезнаходження';
+126
View File
@@ -117,6 +117,49 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => '蓝牙'; String get connectionChoiceBluetoothLabel => '蓝牙';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => '通过 TCP 连接';
@override
String get tcpHostLabel => 'IP地址';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => '端口';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => '输入目标地址,然后连接';
@override
String tcpStatus_connectingTo(String endpoint) {
return '连接到 $endpoint...';
}
@override
String get tcpErrorHostRequired => '需要提供IP地址。';
@override
String get tcpErrorPortInvalid => '端口号必须在 1 到 65535 之间。';
@override
String get tcpErrorUnsupported => '此平台不支持 TCP 传输。';
@override
String get tcpErrorTimedOut => 'TCP 连接超时。';
@override
String tcpConnectionFailed(String error) {
return 'TCP 连接失败:$error';
}
@override @override
String get usbScreenTitle => '通过USB连接'; String get usbScreenTitle => '通过USB连接';
@@ -331,6 +374,47 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get settings_privacyModeDisabled => '隐私模式已关闭'; String get settings_privacyModeDisabled => '隐私模式已关闭';
@override
String get settings_privacy => '隐私设置';
@override
String get settings_privacySubtitle => '控制要共享的信息。';
@override
String get settings_privacySettingsDescription => '选择您的设备与他人共享的信息。';
@override
String get settings_denyAll => '拒绝所有';
@override
String get settings_allowByContact => '按联系人标志允许';
@override
String get settings_allowAll => '允许全部';
@override
String get settings_telemetryBaseMode => '遥测基础模式';
@override
String get settings_telemetryLocationMode => '遥测位置模式';
@override
String get settings_telemetryEnvironmentMode => '遥测环境模式';
@override
String get settings_advertLocation => '广告位置';
@override
String get settings_advertLocationSubtitle => '在广告中包含位置';
@override
String settings_multiAck(String value) {
return '多重ACK$value';
}
@override
String get settings_telemetryModeUpdated => '遥测模式已更新';
@override @override
String get settings_actions => '操作'; String get settings_actions => '操作';
@@ -802,6 +886,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => '请输入群聊名称'; String get contacts_groupNameRequired => '请输入群聊名称';
@override
String get contacts_groupNameReserved => '该群组名称已被保留';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return '名为 \"$name\" 的群聊已存在'; return '名为 \"$name\" 的群聊已存在';
@@ -840,6 +927,39 @@ class AppLocalizationsZh extends AppLocalizations {
return '最后在线 $days 天前'; return '最后在线 $days 天前';
} }
@override
String get contact_info => '联系信息';
@override
String get contact_settings => '联系人设置';
@override
String get contact_telemetry => '遥测数据';
@override
String get contact_lastSeen => '最近出现';
@override
String get contact_clearChat => '清除聊天记录';
@override
String get contact_teleBase => '遥测基站';
@override
String get contact_teleBaseSubtitle => '允许共享电池电量和基本遥测数据';
@override
String get contact_teleLoc => '遥测位置';
@override
String get contact_teleLocSubtitle => '允许共享位置数据';
@override
String get contact_teleEnv => '遥测环境';
@override
String get contact_teleEnvSubtitle => '允许共享环境传感器数据';
@override @override
String get channels_title => '频道'; String get channels_title => '频道';
@@ -1378,6 +1498,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_shareMarkerHere => '在此分享标记'; String get map_shareMarkerHere => '在此分享标记';
@override
String get map_setAsMyLocation => '设置为我的位置';
@override @override
String get map_pinLabel => '标签'; String get map_pinLabel => '标签';
@@ -1440,6 +1563,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_showGuessedLocations => '显示猜测的节点位置'; String get map_showGuessedLocations => '显示猜测的节点位置';
@override
String get map_showDiscoveryContacts => '显示发现联系人';
@override @override
String get map_guessedLocation => '猜测的位置'; String get map_guessedLocation => '猜测的位置';
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbStatus_notConnected": "Selecteer een USB-apparaat", "usbStatus_notConnected": "Selecteer een USB-apparaat",
"usbStatus_connecting": "Verbinding maken met USB-apparaat...", "usbStatus_connecting": "Verbinding maken met USB-apparaat...",
"usbStatus_searching": "Zoeken naar USB-apparaten...", "usbStatus_searching": "Zoeken naar USB-apparaten...",
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft." "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpScreenTitle": "Verbind via TCP",
"tcpHostLabel": "IP-adres",
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Poort",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Voer het eindpunt in en verbind",
"tcpStatus_connectingTo": "Verbinding maken met {endpoint}...",
"tcpErrorHostRequired": "Een IP-adres is vereist.",
"tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.",
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie",
"settings_privacy": "Privacyinstellingen",
"settings_privacySubtitle": "Beheer welke informatie wordt gedeeld",
"settings_telemetryLocationMode": "Telemetrie-locatiemodus",
"settings_telemetryEnvironmentMode": "Telemetrie-omgevingsmodus",
"settings_advertLocation": "Advertentielocatie",
"settings_advertLocationSubtitle": "Locatie opnemen in advertentie",
"settings_privacySettingsDescription": "Kies welke informatie uw apparaat deelt met anderen",
"settings_allowByContact": "Toestaan op basis van contactvlaggen",
"settings_allowAll": "Alles toestaan",
"settings_denyAll": "Weiger alles",
"contact_info": "Contactinformatie",
"settings_telemetryBaseMode": "Telemetrie-basismodus",
"contact_teleBase": "Telemetrie_basis",
"contact_teleLoc": "Telemetrielocatie",
"contact_teleLocSubtitle": "Locatiegegevens delen toestaan",
"contact_teleEnv": "Telemetrieomgeving",
"contact_teleEnvSubtitle": "Delen van omgevingsensordata toestaan",
"contact_settings": "Contactinstellingen",
"contact_telemetry": "Telemetrie",
"contact_lastSeen": "Laatst gezien",
"contact_clearChat": "Chat leegmaken",
"contact_teleBaseSubtitle": "Sta delen van batterij niveau en basis telemetrie toe",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Telemetrie-modus bijgewerkt",
"settings_multiAck": "Multi-ACKs: {value}"
} }
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbStatus_connecting": "Połączenie z urządzeniem USB...", "usbStatus_connecting": "Połączenie z urządzeniem USB...",
"usbStatus_notConnected": "Wybierz urządzenie USB", "usbStatus_notConnected": "Wybierz urządzenie USB",
"usbConnectionFailed": "Błąd połączenia USB: {error}", "usbConnectionFailed": "Błąd połączenia USB: {error}",
"usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"." "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Połącz się za pomocą protokołu TCP",
"tcpHostLabel": "Adres IP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Wprowadź adres URL i połącz",
"tcpStatus_connectingTo": "Połączenie z {endpoint}...",
"tcpErrorHostRequired": "Wymagana jest adresa IP.",
"tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.",
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
"map_setAsMyLocation": "Ustaw jako moje lokalizację",
"settings_allowByContact": "Zezwalaj według flag kontaktowych",
"settings_allowAll": "Zezwalaj na wszystko",
"settings_telemetryLocationMode": "Tryb położenia telemetrycznego",
"settings_telemetryEnvironmentMode": "Tryb środowiska telemetrycznego",
"settings_advertLocation": "Lokalizacja reklamowa",
"settings_advertLocationSubtitle": "Uwzględnij lokalizację w ogłoszeniu",
"settings_denyAll": "Odmów wszystkim",
"settings_privacySubtitle": "Kontroluj jakie informacje są udostępniane.",
"settings_privacy": "Ustawienia prywatności",
"settings_privacySettingsDescription": "Wybierz jakie informacje urządzenie udostępni innym.",
"contact_info": "Informacje kontaktowe",
"settings_telemetryBaseMode": "Tryb podstawowy telemetrii",
"contact_teleBase": "Baza telemetryczna",
"contact_teleLoc": "Lokalizacja telemetryczna",
"contact_teleLocSubtitle": "Zezwalaj na udostępnianie danych lokalizacji",
"contact_teleEnv": "Środowisko telemetryczne",
"contact_teleEnvSubtitle": "Zezwalaj na udostępnianie danych czujników środowiskowych",
"contact_telemetry": "Telemetryka",
"contact_clearChat": "Wyczyść czat",
"contact_settings": "Ustawienia kontaktowe",
"contact_lastSeen": "Ostatnio widziany",
"contact_teleBaseSubtitle": "Pozwól na udostępnianie poziomu naładowania baterii i podstawowych danych telemetrycznych",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Tryb telemetryczny zaktualizowany",
"settings_multiAck": "Wiele potwierdzeń: {value}"
} }
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbStatus_notConnected": "Selecione um dispositivo USB", "usbStatus_notConnected": "Selecione um dispositivo USB",
"usbConnectionFailed": "Falha na conexão USB: {error}", "usbConnectionFailed": "Falha na conexão USB: {error}",
"usbStatus_connecting": "Conectando ao dispositivo USB...", "usbStatus_connecting": "Conectando ao dispositivo USB...",
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion." "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Endereço IP",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Estabelecer conexão via TCP",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Porta",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Insira o endereço final e conecte-se.",
"tcpStatus_connectingTo": "Conectando a {endpoint}...",
"tcpErrorHostRequired": "É necessário fornecer um endereço IP.",
"tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.",
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
"tcpErrorTimedOut": "A conexão TCP expirou.",
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
"map_setAsMyLocation": "Defina minha localização",
"settings_privacySettingsDescription": "Escolha quais informações o seu dispositivo compartilha com os outros.",
"settings_allowByContact": "Permitir por bandeiras de contato",
"settings_telemetryLocationMode": "Modo de Localização de Telemetria",
"settings_telemetryEnvironmentMode": "Modo de Ambiente de Telemetria",
"settings_advertLocation": "Localização do Anúncio",
"settings_advertLocationSubtitle": "Incluir localização no anúncio",
"settings_privacySubtitle": "Controle o que é compartilhado.",
"settings_denyAll": "Negar todos",
"settings_allowAll": "Permitir todos",
"settings_privacy": "Configurações de Privacidade",
"contact_info": "Informações de Contato",
"settings_telemetryBaseMode": "Modo Base de Telemetria",
"contact_teleBase": "Base de Telemetria",
"contact_teleLoc": "Localização de Telemetria",
"contact_teleLocSubtitle": "Permitir compartilhamento de dados de localização",
"contact_teleEnv": "Ambiente de Telemetria",
"contact_teleEnvSubtitle": "Permitir compartilhamento de dados do sensor de ambiente",
"contact_lastSeen": "Visto pela última vez",
"contact_clearChat": "Limpar Chat",
"contact_telemetry": "Telemetria",
"contact_settings": "Configurações de Contato",
"contact_teleBaseSubtitle": "Permitir compartilhamento do nível da bateria e telemetria básica",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Modo de telemetria atualizado",
"settings_multiAck": "Multi-ACKs: {value}"
} }
+62 -1
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": "Нет контактов, соответствующих фильтру",
@@ -1099,5 +1100,65 @@
"usbStatus_connecting": "Подключение к USB-устройству...", "usbStatus_connecting": "Подключение к USB-устройству...",
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}", "usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
"usbStatus_notConnected": "Выберите USB-устройство", "usbStatus_notConnected": "Выберите USB-устройство",
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion." "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP-адрес",
"tcpScreenTitle": "Установить соединение по протоколу TCP",
"tcpPortLabel": "Порт",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Введите адрес и подключитесь.",
"tcpStatus_connectingTo": "Подключение к {endpoint}...",
"tcpErrorHostRequired": "Необходимо указать IP-адрес.",
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение",
"settings_privacy": "Настройки конфиденциальности",
"settings_privacySubtitle": "Контролируйте, какую информацию делиться.",
"settings_telemetryLocationMode": "Режим местоположения телеметрии",
"settings_telemetryEnvironmentMode": "Режим среды телеметрии",
"settings_advertLocation": "Местоположение рекламы",
"settings_advertLocationSubtitle": "Включить местоположение в объявление",
"settings_allowAll": "Разрешить все",
"settings_privacySettingsDescription": "Выберите, какую информацию ваше устройство будет делиться с другими.",
"settings_denyAll": "Отклонить все",
"settings_allowByContact": "Разрешить по флагам контактов",
"contact_info": "Контактная информация",
"settings_telemetryBaseMode": "Базовый режим телеметрии",
"contact_teleBase": "База телеметрии",
"contact_teleLoc": "Местоположение телеметрии",
"contact_teleLocSubtitle": "Разрешить обмен данными о местоположении",
"contact_teleEnv": "Среда телеметрии",
"contact_teleEnvSubtitle": "Разрешить обмен данными датчиков окружающей среды",
"contact_settings": "Настройки контактов",
"contact_telemetry": "Телеметрия",
"contact_clearChat": "Очистить чат",
"contact_lastSeen": "Последний раз видели",
"contact_teleBaseSubtitle": "Разрешить обмен уровнем заряда батареи и базовой телеметрией",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
"settings_multiAck": "Мульти-ACK: {value}"
} }
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}", "usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
"usbStatus_notConnected": "Vyberte USB zariadenie", "usbStatus_notConnected": "Vyberte USB zariadenie",
"usbStatus_connecting": "Pripojenie k USB zariadeniu...", "usbStatus_connecting": "Pripojenie k USB zariadeniu...",
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion." "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP adresa",
"tcpScreenTitle": "Spojte sa pomocou protokolu TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.",
"tcpStatus_connectingTo": "Pripojenie k {endpoint}...",
"tcpErrorHostRequired": "Je potrebné zadať IP adresu.",
"tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.",
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu",
"settings_privacy": "Nastavenia súkromia",
"settings_privacySubtitle": "Ovládni, aké informácie sa zdieľajú.",
"settings_telemetryLocationMode": "Režim umiestnenia telemetrie",
"settings_telemetryBaseMode": "Základný režim telemetrie",
"settings_advertLocation": "Umiestnenie inzerátu",
"settings_telemetryEnvironmentMode": "Režim prostredia telemetrie",
"settings_advertLocationSubtitle": "Zahrnúť polohu do inzerátu",
"settings_allowAll": "Povoliť všetko",
"settings_privacySettingsDescription": "Vyberte, ktoré informácie váš zariadenie zdieľa s ostatnými.",
"settings_denyAll": "Zamietnuť všetko",
"settings_allowByContact": "Povoliť podľa kontaktových vlajok",
"contact_info": "Kontaktné informácie",
"contact_settings": "Nastavenia kontaktov",
"contact_teleBaseSubtitle": "Povoliť zdieľanie úrovne batérie a základnej telemetrie",
"contact_teleLoc": "Lokácia telemetrie",
"contact_teleLocSubtitle": "Povoliť zdieľanie údajov o lokalite",
"contact_teleEnv": "Prostredie telemetrie",
"contact_telemetry": "Telemetria",
"contact_clearChat": "Vymazať chat",
"contact_lastSeen": "Naposledy videný",
"contact_teleBase": "Báza telemetrie",
"contact_teleEnvSubtitle": "Povoliť zdieľanie údajov senzorov prostredia",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Režim telemetrie bol aktualizovaný",
"settings_multiAck": "Viaceré ACK: {value}"
} }
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbStatus_connecting": "Povezava z USB napravo...", "usbStatus_connecting": "Povezava z USB napravo...",
"usbStatus_searching": "Iskanje USB naprav...", "usbStatus_searching": "Iskanje USB naprav...",
"usbConnectionFailed": "Napaka pri povezavi preko USB: {error}", "usbConnectionFailed": "Napaka pri povezavi preko USB: {error}",
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion." "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP naslov",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Komunicirajte preko protokola TCP",
"tcpPortLabel": "Vrata",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Vnesite končni naslov in se povežite",
"tcpStatus_connectingTo": "Povezava z {endpoint}...",
"tcpErrorHostRequired": "Potrebna je IP-naslov.",
"tcpErrorPortInvalid": "Port mora biti med 1 in 65535.",
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo",
"settings_privacy": "Nastavitve zasebnosti",
"settings_privacySettingsDescription": "Izberite, katere informacije vaš naprava deli z drugimi.",
"settings_telemetryBaseMode": "Osnovni način telemetrije",
"settings_telemetryLocationMode": "Način delovanja telemetrije",
"settings_telemetryEnvironmentMode": "Način delovanja okolja telemetrije",
"settings_advertLocation": "Lokacija oglasa",
"settings_allowByContact": "Dovoli po kontaktnih zastavah",
"settings_denyAll": "Zavrniti vse",
"settings_allowAll": "Dovoli vse",
"settings_privacySubtitle": "Kontrolirajte, katere informacije so deljene.",
"contact_info": "Kontaktni podatki",
"contact_teleBase": "Baza telemetrije",
"contact_teleBaseSubtitle": "Dovoli deljenje stanja baterije in osnovne telemetrije",
"contact_teleLoc": "Lokacija telemetrije",
"contact_lastSeen": "Zadnjič videno",
"contact_settings": "Nastavitve stika",
"settings_advertLocationSubtitle": "Vključi lokacijo v oglas.",
"contact_telemetry": "Telemetrija",
"contact_clearChat": "Počisti klepet",
"contact_teleEnv": "Okolje telemetrije",
"contact_teleEnvSubtitle": "Dovoli deljenje podatkov okoljskih senzorjev",
"contact_teleLocSubtitle": "Dovoli deljenje podatkov o lokaciji",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_multiAck": "Večkratni potrditvi: {value}",
"settings_telemetryModeUpdated": "Način telemetrije posodobljen"
} }
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbStatus_notConnected": "Välj en USB-enhet", "usbStatus_notConnected": "Välj en USB-enhet",
"usbConnectionFailed": "Fel vid USB-anslutning: {error}", "usbConnectionFailed": "Fel vid USB-anslutning: {error}",
"usbStatus_searching": "Söker efter USB-enheter...", "usbStatus_searching": "Söker efter USB-enheter...",
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware." "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP-adress",
"tcpScreenTitle": "Anslut via TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Ange slutpunkt och anslut",
"tcpStatus_connectingTo": "Anslutning till {endpoint}...",
"tcpErrorHostRequired": "IP-adress krävs.",
"tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.",
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats",
"settings_privacy": "Inställningar för sekretess",
"settings_allowAll": "Tillåt alla",
"settings_privacySubtitle": "Kontrollera vilken information som delas.",
"settings_telemetryEnvironmentMode": "Telemetri miljöläge",
"settings_telemetryBaseMode": "Telemetribasläge",
"settings_telemetryLocationMode": "Telemetritillstånd för plats",
"settings_advertLocation": "Annonsplacering",
"contact_info": "Kontaktinformation",
"contact_settings": "Kontaktinställningar",
"contact_telemetry": "Telemetri",
"settings_denyAll": "Neka alla",
"settings_allowByContact": "Tillåt via kontaktflaggor",
"settings_privacySettingsDescription": "Välj vilken information din enhet delar med andra.",
"contact_lastSeen": "Senast sedd",
"contact_clearChat": "Rensa Chatt",
"contact_teleEnv": "Telemetri Miljö",
"settings_advertLocationSubtitle": "Inkludera plats i annonsen",
"contact_teleEnvSubtitle": "Tillåt delning av miljösensordata",
"contact_teleBase": "Telemetribas",
"contact_teleBaseSubtitle": "Tillåt delning av batterinivå och grundläggande telemetri",
"contact_teleLoc": "Telemetridata plats",
"contact_teleLocSubtitle": "Tillåt delning av platsdata",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Telemetri-läge uppdaterat",
"settings_multiAck": "Multi-ACKs: {value}"
} }
+62 -1
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": {
@@ -1859,5 +1860,65 @@
"usbStatus_notConnected": "Виберіть пристрій USB", "usbStatus_notConnected": "Виберіть пристрій USB",
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}", "usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
"usbStatus_connecting": "Підключення до USB-пристрою...", "usbStatus_connecting": "Підключення до USB-пристрою...",
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion." "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP-адреса",
"tcpScreenTitle": "З'єднатися через протокол TCP",
"tcpPortLabel": "Порт",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Введіть кінцеву точку та підключіться",
"tcpStatus_connectingTo": "Підключення до {endpoint}...",
"tcpErrorHostRequired": "Необхідно вказати IP-адресу.",
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження",
"settings_privacySubtitle": "Керуйте інформацією, яку буде спільно використовуватися",
"settings_privacy": "Налаштування приватності",
"settings_telemetryBaseMode": "Режим базової телеметрії",
"settings_telemetryLocationMode": "Режим місця телеметрії",
"settings_advertLocation": "Розміщення реклами",
"settings_advertLocationSubtitle": "Включити місце розташування в оголошення",
"settings_privacySettingsDescription": "Виберіть, яку інформацію ваш пристрій буде передавати іншим.",
"settings_allowAll": "Дозволити все",
"settings_denyAll": "Відхилити все",
"settings_allowByContact": "Дозволити за контактними прапорцями",
"settings_telemetryEnvironmentMode": "Режим середовища телеметрії",
"contact_info": "Контактна інформація",
"contact_teleBaseSubtitle": "Дозволити спільний доступ до рівня заряду батареї та базової телеметрії",
"contact_teleLoc": "Розташування телеметрії",
"contact_teleBase": "Базовий телебачення",
"contact_teleLocSubtitle": "Дозволити спільне використання даних про місцеположення",
"contact_settings": "Налаштування контактів",
"contact_telemetry": "Телеметрія",
"contact_clearChat": "Очистити чат",
"contact_lastSeen": "Останній раз бачили",
"contact_teleEnv": "Середовище телеметрії",
"contact_teleEnvSubtitle": "Дозволити спільний доступ до даних датчиків середовища",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Режим телеметрії оновлено",
"settings_multiAck": "Багатократне підтвердження: {value}"
} }
+62 -1
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": {
@@ -1864,5 +1865,65 @@
"usbStatus_connecting": "连接USB设备...", "usbStatus_connecting": "连接USB设备...",
"usbStatus_notConnected": "选择一个 USB 设备", "usbStatus_notConnected": "选择一个 USB 设备",
"usbConnectionFailed": "USB 连接失败:{error}", "usbConnectionFailed": "USB 连接失败:{error}",
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。" "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "IP地址",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "通过 TCP 连接",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "端口",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "输入目标地址,然后连接",
"tcpStatus_connectingTo": "连接到 {endpoint}...",
"tcpErrorHostRequired": "需要提供IP地址。",
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置",
"settings_privacySubtitle": "控制要共享的信息。",
"settings_privacySettingsDescription": "选择您的设备与他人共享的信息。",
"settings_telemetryBaseMode": "遥测基础模式",
"settings_telemetryLocationMode": "遥测位置模式",
"settings_advertLocation": "广告位置",
"settings_advertLocationSubtitle": "在广告中包含位置",
"settings_allowByContact": "按联系人标志允许",
"settings_denyAll": "拒绝所有",
"settings_privacy": "隐私设置",
"settings_allowAll": "允许全部",
"contact_info": "联系信息",
"contact_teleBase": "遥测基站",
"contact_teleBaseSubtitle": "允许共享电池电量和基本遥测数据",
"settings_telemetryEnvironmentMode": "遥测环境模式",
"contact_teleLoc": "遥测位置",
"contact_teleEnv": "遥测环境",
"contact_teleEnvSubtitle": "允许共享环境传感器数据",
"contact_clearChat": "清除聊天记录",
"contact_lastSeen": "最近出现",
"contact_settings": "联系人设置",
"contact_teleLocSubtitle": "允许共享位置数据",
"contact_telemetry": "遥测数据",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_multiAck": "多重ACK{value}",
"settings_telemetryModeUpdated": "遥测模式已更新"
} }
+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) {
+20
View File
@@ -39,6 +39,9 @@ class AppSettings {
final Map<String, String> batteryChemistryByRepeaterId; final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem; final UnitSystem unitSystem;
final Set<String> mutedChannels; final Set<String> mutedChannels;
final bool mapShowDiscoveryContacts;
final String tcpServerAddress;
final int tcpServerPort;
AppSettings({ AppSettings({
this.clearPathOnMaxRetry = false, this.clearPathOnMaxRetry = false,
@@ -66,6 +69,9 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId, Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric, this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels, Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true,
this.tcpServerAddress = '',
this.tcpServerPort = 0,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {}; mutedChannels = mutedChannels ?? {};
@@ -97,6 +103,9 @@ class AppSettings {
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value, 'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(), 'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts,
'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort,
}; };
} }
@@ -152,6 +161,10 @@ class AppSettings {
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toSet()) ?? .toSet()) ??
{}, {},
mapShowDiscoveryContacts:
json['map_show_discovery_contacts'] as bool? ?? true,
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
); );
} }
@@ -181,6 +194,9 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId, Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem, UnitSystem? unitSystem,
Set<String>? mutedChannels, Set<String>? mutedChannels,
bool? mapShowDiscoveryContacts,
String? tcpServerAddress,
int? tcpServerPort,
}) { }) {
return AppSettings( return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -217,6 +233,10 @@ class AppSettings {
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
unitSystem: unitSystem ?? this.unitSystem, unitSystem: unitSystem ?? this.unitSystem,
mutedChannels: mutedChannels ?? this.mutedChannels, mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
); );
} }
} }
+47 -58
View File
@@ -1,4 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
class Contact { class Contact {
@@ -15,6 +17,8 @@ class Contact {
final double? longitude; final double? longitude;
final DateTime lastSeen; final DateTime lastSeen;
final DateTime lastMessageAt; final DateTime lastMessageAt;
final bool isActive;
final Uint8List? rawPacket;
Contact({ Contact({
required this.publicKey, required this.publicKey,
@@ -29,6 +33,8 @@ class Contact {
this.longitude, this.longitude,
required this.lastSeen, required this.lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
this.isActive = true,
this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen; }) : lastMessageAt = lastMessageAt ?? lastSeen;
String get publicKeyHex => pubKeyToHex(publicKey); String get publicKeyHex => pubKeyToHex(publicKey);
@@ -59,7 +65,17 @@ class Contact {
return '$pathLength hops'; return '$pathLength hops';
} }
bool get hasLocation => latitude != null && longitude != null; bool get hasLocation {
const double epsilon = 1e-6;
final lat = latitude ?? 0.0;
final lon = longitude ?? 0.0;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
bool get isFavorite => (flags & contactFlagFavorite) != 0; bool get isFavorite => (flags & contactFlagFavorite) != 0;
Contact copyWith({ Contact copyWith({
@@ -76,6 +92,8 @@ class Contact {
double? longitude, double? longitude,
DateTime? lastSeen, DateTime? lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
bool? isActive,
Uint8List? rawPacket,
}) { }) {
return Contact( return Contact(
publicKey: publicKey ?? this.publicKey, publicKey: publicKey ?? this.publicKey,
@@ -94,11 +112,13 @@ class Contact {
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen, lastSeen: lastSeen ?? this.lastSeen,
lastMessageAt: lastMessageAt ?? this.lastMessageAt, lastMessageAt: lastMessageAt ?? this.lastMessageAt,
isActive: isActive ?? this.isActive,
rawPacket: rawPacket ?? this.rawPacket,
); );
} }
String get pathIdList { String get pathIdList {
final pathBytes = _pathBytesForDisplay; final pathBytes = pathBytesForDisplay;
if (pathBytes.isEmpty) return ''; if (pathBytes.isEmpty) return '';
final parts = <String>[]; final parts = <String>[];
final groupSize = pathHashSize; final groupSize = pathHashSize;
@@ -120,43 +140,7 @@ class Contact {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>"; return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
} }
Uint8List? get traceRouteBytes { Uint8List get pathBytesForDisplay {
final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes;
if (pathBytes.isEmpty) {
traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0];
return traceBytes;
}
if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
}
Uint8List get _pathBytesForDisplay {
if (pathOverride != null) { if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0); if (pathOverride! < 0) return Uint8List(0);
return pathOverrideBytes ?? Uint8List(0); return pathOverrideBytes ?? Uint8List(0);
@@ -166,28 +150,28 @@ class Contact {
static Contact? fromFrame(Uint8List data) { static Contact? fromFrame(Uint8List data) {
if (data.isEmpty) return null; if (data.isEmpty) return null;
if (data[0] != respCodeContact) return null; final reader = BufferReader(data);
try { try {
final pubKey = Uint8List.fromList( final respCode = reader.readByte();
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
); return null;
final type = data[contactTypeOffset]; }
final flags = data[contactFlagsOffset]; final pubKey = reader.readBytes(pubKeySize);
final pathLen = data[contactPathLenOffset].toSigned(8); final type = reader.readByte();
final flags = reader.readByte();
final pathLen = reader.readByte();
final safePathLen = pathLen > 0 final safePathLen = pathLen > 0
? (pathLen > maxPathSize ? maxPathSize : pathLen) ? (pathLen > maxPathSize ? maxPathSize : pathLen)
: 0; : 0;
final pathBytes = safePathLen > 0 final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
? Uint8List.fromList( final name = reader.readCStringGreedy(maxNameSize);
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
) final lastMod = reader.readUInt32LE();
: Uint8List(0);
final name = readCString(data, contactNameOffset, maxNameSize);
final lastmod = readUint32LE(data, contactLastModOffset);
double? lat, lon; double? lat, lon;
final latRaw = readInt32LE(data, contactLatOffset); final latRaw = reader.readInt32LE();
final lonRaw = readInt32LE(data, contactLonOffset); final lonRaw = reader.readInt32LE();
if (latRaw != 0 || lonRaw != 0) { if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6; lat = latRaw / 1e6;
lon = lonRaw / 1e6; lon = lonRaw / 1e6;
@@ -198,14 +182,16 @@ class Contact {
name: name.isEmpty ? 'Unknown' : name, name: name.isEmpty ? 'Unknown' : name,
type: type, type: type,
flags: flags, flags: flags,
pathLength: pathLen, pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
path: pathBytes, path: pathBytes,
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
isActive: true,
rawPacket: null,
); );
} catch (e) { } catch (e) {
// If parsing fails, return null appLogger.error('Failed to parse contact frame: $e');
return null; return null;
} }
} }
@@ -216,4 +202,7 @@ class Contact {
@override @override
int get hashCode => publicKeyHex.hashCode; int get hashCode => publicKeyHex.hashCode;
bool get teleBaseEnabled => (flags & contactFlagTeleBase) != 0;
bool get teleLocEnabled => (flags & contactFlagTeleLoc) != 0;
bool get teleEnvEnabled => (flags & contactFlagTeleEnv) != 0;
} }
+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),
);
}
}
-105
View File
@@ -1,105 +0,0 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
class DiscoveryContact {
final Uint8List rawPacket;
final Uint8List publicKey;
final String name;
final int type;
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; // Path bytes from device
final double? latitude;
final double? longitude;
final DateTime lastSeen;
DiscoveryContact({
required this.rawPacket,
required this.publicKey,
required this.name,
required this.type,
required this.pathLength,
required this.path,
this.latitude,
this.longitude,
required this.lastSeen,
});
String get publicKeyHex => pubKeyToHex(publicKey);
String get typeLabel {
switch (type) {
case advTypeChat:
return 'Chat';
case advTypeRepeater:
return 'Repeater';
case advTypeRoom:
return 'Room';
case advTypeSensor:
return 'Sensor';
default:
return 'Unknown';
}
}
String get pathLabel {
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
}
bool get hasLocation => latitude != null && longitude != null;
DiscoveryContact copyWith({
Uint8List? rawPacket,
Uint8List? publicKey,
String? name,
int? type,
int? pathLength,
Uint8List? path,
double? latitude,
double? longitude,
DateTime? lastSeen,
}) {
return DiscoveryContact(
rawPacket: rawPacket ?? this.rawPacket,
publicKey: publicKey ?? this.publicKey,
name: name ?? this.name,
type: type ?? this.type,
pathLength: pathLength ?? this.pathLength,
path: path ?? this.path,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen,
);
}
String get pathIdList {
final pathBytes = path;
if (pathBytes.isEmpty) return '';
final parts = <String>[];
final groupSize = pathHashSize;
for (int i = 0; i < pathBytes.length; i += groupSize) {
final end = (i + groupSize) <= pathBytes.length
? (i + groupSize)
: pathBytes.length;
final chunk = pathBytes.sublist(i, end);
parts.add(
chunk
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(),
);
}
return parts.join(',');
}
String get shortPubKeyHex {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
}
@override
bool operator ==(Object other) =>
other is DiscoveryContact && publicKeyHex == other.publicKeyHex;
@override
int get hashCode => publicKeyHex.hashCode;
}
+13
View File
@@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
: Icons.download, : Icons.download,
size: 18, size: 18,
), ),
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
text: entry.payload
.map(
(b) => b
.toRadixString(16)
.padLeft(2, '0'),
)
.join(''),
),
);
},
); );
} }
+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,
+7 -9
View File
@@ -40,8 +40,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList()) ? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp; : primaryPathTmp;
final contacts = connector.allContacts;
final hops = _buildPathHops(primaryPath, connector.contacts, l10n); final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
primaryPath.length, primaryPath.length,
@@ -62,8 +62,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: primaryPath, path: primaryPath,
flipPathRound: true, flipPathAround: true,
reversePathRound: !message.isOutgoing && !channelMessage, reversePathAround:
!(!channelMessage && !message.isOutgoing),
), ),
), ),
), ),
@@ -364,11 +365,8 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp; : selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops( final contacts = connector.allContacts;
selectedPath, final hops = _buildPathHops(selectedPath, contacts, context.l10n);
connector.contacts,
context.l10n,
);
final points = <LatLng>[]; final points = <LatLng>[];
+78 -65
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,17 +42,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore(); final CommunityStore _communityStore = CommunityStore();
String _searchQuery = '';
Timer? _searchDebounce; Timer? _searchDebounce;
ChannelSortOption _sortOption = ChannelSortOption.manual;
List<Community> _communities = []; List<Community> _communities = [];
// Cache of PSK hex -> Community for quick lookup // Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {}; final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController.text = context
.read<UiViewStateService>()
.channelsSearchText;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MeshCoreConnector>().getChannels(); context.read<MeshCoreConnector>().getChannels();
_loadCommunities(); _loadCommunities();
@@ -61,6 +63,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
Future<void> _loadCommunities() async { Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities(); final communities = await _communityStore.loadCommunities();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -106,7 +110,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final viewState = context.watch<UiViewStateService>();
final channelMessageStore = ChannelMessageStore(); final channelMessageStore = ChannelMessageStore();
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
// Auto-navigate back to scanner if disconnected // Auto-navigate back to scanner if disconnected
if (!checkConnectionAndNavigate(connector)) { if (!checkConnectionAndNavigate(connector)) {
@@ -199,6 +206,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final filteredChannels = _filterAndSortChannels( final filteredChannels = _filterAndSortChannels(
channels, channels,
connector, connector,
viewState,
); );
return Column( return Column(
@@ -213,17 +221,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
suffixIcon: Row( suffixIcon: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_searchQuery.isNotEmpty) if (viewState.channelsSearchText.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
_searchDebounce?.cancel();
_searchDebounce = null;
_searchController.clear(); _searchController.clear();
setState(() { context
_searchQuery = ''; .read<UiViewStateService>()
}); .setChannelsSearchText('');
}, },
), ),
_buildFilterButton(), _buildFilterButton(viewState),
], ],
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -240,9 +250,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const Duration(milliseconds: 300), const Duration(milliseconds: 300),
() { () {
if (!mounted) return; if (!mounted) return;
setState(() { context
_searchQuery = value.toLowerCase(); .read<UiViewStateService>()
}); .setChannelsSearchText(value);
}, },
); );
}, },
@@ -277,8 +287,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
), ),
], ],
) )
: (_sortOption == ChannelSortOption.manual && : (viewState.channelsSortOption ==
_searchQuery.isEmpty) ChannelSortOption.manual &&
viewState.channelsSearchText.isEmpty)
? ReorderableListView.builder( ? ReorderableListView.builder(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 16, left: 16,
@@ -578,59 +589,40 @@ class _ChannelsScreenState extends State<ChannelsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
Widget _buildFilterButton() { Widget _buildFilterButton(UiViewStateService viewState) {
const actionSortManual = 0; return SortFilterMenu<ChannelSortOption>(
const actionSortName = 1;
const actionSortLatest = 2;
const actionSortUnread = 3;
return SortFilterMenu(
tooltip: context.l10n.listFilter_tooltip, tooltip: context.l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection( SortFilterMenuSection<ChannelSortOption>(
title: context.l10n.channels_sortBy, title: context.l10n.channels_sortBy,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortManual, value: ChannelSortOption.manual,
label: context.l10n.channels_sortManual, label: context.l10n.channels_sortManual,
checked: _sortOption == ChannelSortOption.manual, checked: viewState.channelsSortOption == ChannelSortOption.manual,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortName, value: ChannelSortOption.name,
label: context.l10n.channels_sortAZ, label: context.l10n.channels_sortAZ,
checked: _sortOption == ChannelSortOption.name, checked: viewState.channelsSortOption == ChannelSortOption.name,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortLatest, value: ChannelSortOption.latestMessages,
label: context.l10n.channels_sortLatestMessages, label: context.l10n.channels_sortLatestMessages,
checked: _sortOption == ChannelSortOption.latestMessages, checked:
viewState.channelsSortOption ==
ChannelSortOption.latestMessages,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortUnread, value: ChannelSortOption.unread,
label: context.l10n.channels_sortUnread, label: context.l10n.channels_sortUnread,
checked: _sortOption == ChannelSortOption.unread, checked: viewState.channelsSortOption == ChannelSortOption.unread,
), ),
], ],
), ),
], ],
onSelected: (action) { onSelected: (sortOption) {
setState(() { viewState.setChannelsSortOption(sortOption);
switch (action) {
case actionSortManual:
_sortOption = ChannelSortOption.manual;
break;
case actionSortLatest:
_sortOption = ChannelSortOption.latestMessages;
break;
case actionSortUnread:
_sortOption = ChannelSortOption.unread;
break;
case actionSortName:
default:
_sortOption = ChannelSortOption.name;
break;
}
});
}, },
); );
} }
@@ -638,11 +630,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
List<Channel> _filterAndSortChannels( List<Channel> _filterAndSortChannels(
List<Channel> channels, List<Channel> channels,
MeshCoreConnector connector, MeshCoreConnector connector,
UiViewStateService viewState,
) { ) {
var filtered = channels.where((channel) { var filtered = channels.where((channel) {
if (_searchQuery.isEmpty) return true; if (viewState.channelsSearchText.isEmpty) return true;
final label = _normalizeChannelName(channel); final label = _normalizeChannelName(channel);
return label.toLowerCase().contains(_searchQuery); return label.toLowerCase().contains(
viewState.channelsSearchText.toLowerCase(),
);
}).toList(); }).toList();
int compareByName(Channel a, Channel b) { int compareByName(Channel a, Channel b) {
@@ -651,7 +646,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return nameA.toLowerCase().compareTo(nameB.toLowerCase()); return nameA.toLowerCase().compareTo(nameB.toLowerCase());
} }
switch (_sortOption) { switch (viewState.channelsSortOption) {
case ChannelSortOption.manual: case ChannelSortOption.manual:
break; break;
case ChannelSortOption.latestMessages: case ChannelSortOption.latestMessages:
@@ -712,6 +707,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
bool isRegularHashtag = true; bool isRegularHashtag = true;
Community? selectedCommunity; Community? selectedCommunity;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
@@ -763,7 +760,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
); );
} }
Widget? buildExpandedContent() { Widget? buildExpandedContent(
ChannelMessageStore channelMessageStore,
) {
switch (selectedOption) { switch (selectedOption) {
case 0: // Create Private Channel case 0: // Create Private Channel
return Column( return Column(
@@ -788,7 +787,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [ children: [
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: () { onPressed: () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -810,7 +809,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
psk[i] = random.nextInt(256); psk[i] = random.nextInt(256);
} }
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk); await connector.setChannel(
nextIndex,
name,
psk,
);
await channelMessageStore.clearChannelMessages(
nextIndex,
);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -1329,7 +1335,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_createPrivateChannelDesc, dialogContext.l10n.channels_createPrivateChannelDesc,
), ),
if (selectedOption == 0) buildExpandedContent()!, if (selectedOption == 0)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 1, optionIndex: 1,
@@ -1338,7 +1345,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinPrivateChannelDesc, dialogContext.l10n.channels_joinPrivateChannelDesc,
), ),
if (selectedOption == 1) buildExpandedContent()!, if (selectedOption == 1)
buildExpandedContent(_channelMessageStore)!,
if (!hasPublicChannel) ...[ if (!hasPublicChannel) ...[
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
@@ -1348,7 +1356,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinPublicChannelDesc, dialogContext.l10n.channels_joinPublicChannelDesc,
), ),
if (selectedOption == 2) buildExpandedContent()!, if (selectedOption == 2)
buildExpandedContent(_channelMessageStore)!,
], ],
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
@@ -1358,7 +1367,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinHashtagChannelDesc, dialogContext.l10n.channels_joinHashtagChannelDesc,
), ),
if (selectedOption == 3) buildExpandedContent()!, if (selectedOption == 3)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 4, optionIndex: 4,
@@ -1366,7 +1376,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_scanQr, title: dialogContext.l10n.community_scanQr,
subtitle: dialogContext.l10n.community_join, subtitle: dialogContext.l10n.community_join,
), ),
if (selectedOption == 4) buildExpandedContent()!, if (selectedOption == 4)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 5, optionIndex: 5,
@@ -1374,7 +1385,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_create, title: dialogContext.l10n.community_create,
subtitle: dialogContext.l10n.community_createDesc, subtitle: dialogContext.l10n.community_createDesc,
), ),
if (selectedOption == 5) buildExpandedContent()!, if (selectedOption == 5)
buildExpandedContent(_channelMessageStore)!,
], ],
), ),
), ),
@@ -1524,7 +1536,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try { try {
await connector.deleteChannel(channel.index); await connector.deleteChannel(channel.index);
channelMessageStore.clearChannelMessages(channel.index); await channelMessageStore.clearChannelMessages(channel.index);
if (!context.mounted) return; if (!context.mounted) return;
@@ -1749,6 +1761,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
final channelCount = communityChannels.length; final channelCount = communityChannels.length;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog( showDialog(
context: context, context: context,
+268 -56
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;
@@ -106,10 +107,9 @@ class _ChatScreenState extends State<ChatScreen> {
final unreadLabel = context.l10n.chat_unread(unreadCount); final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override) // Show path details if we have non-empty path data (from device or override)
final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path; final effectivePath = contact.pathOverrideBytes ?? contact.path;
final hasPathData = effectivePath.isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -143,12 +143,25 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final isFloodMode = contact.pathOverride == -1; final isFloodMode = contact.pathOverride == -1;
final isDirectMode = contact.pathOverride == 0;
final activeMode = isFloodMode
? 'flood'
: isDirectMode
? 'direct'
: 'auto';
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: context.l10n.chat_routingMode, tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1); await connector.setPathOverride(contact, pathLen: -1);
} else if (mode == 'direct') {
await connector.setPathOverride(
contact,
pathLen: 0,
pathBytes: Uint8List(0),
);
} else { } else {
await connector.setPathOverride(contact, pathLen: null); await connector.setPathOverride(contact, pathLen: null);
} }
@@ -161,7 +174,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.auto_mode, Icons.auto_mode,
size: 20, size: 20,
color: !isFloodMode color: activeMode == 'auto'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -169,7 +182,30 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_autoUseSavedPath, context.l10n.chat_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode fontWeight: activeMode == 'auto'
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'direct',
child: Row(
children: [
Icon(
Icons.near_me,
size: 20,
color: activeMode == 'direct'
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
context.l10n.chat_direct,
style: TextStyle(
fontWeight: activeMode == 'direct'
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -184,7 +220,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.waves, Icons.waves,
size: 20, size: 20,
color: isFloodMode color: activeMode == 'flood'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -192,7 +228,7 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_forceFloodMode, context.l10n.chat_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode fontWeight: activeMode == 'flood'
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -209,9 +245,77 @@ class _ChatScreenState extends State<ChatScreen> {
tooltip: context.l10n.chat_pathManagement, tooltip: context.l10n.chat_pathManagement,
onPressed: () => _showPathHistory(context), onPressed: () => _showPathHistory(context),
), ),
IconButton( Consumer<MeshCoreConnector>(
icon: const Icon(Icons.info_outline), builder: (context, connector, _) {
onPressed: () => _showContactInfo(context), return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'info') {
_showContactInfo(context);
}
if (value == 'settings') {
_showContactSettings(context);
}
if (value == 'telemetry') {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TelemetryScreen(contact: widget.contact),
),
);
}
if (value == 'clearChat') {
connector.clearMessagesForContact(widget.contact);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'info',
child: Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_info),
],
),
),
PopupMenuItem(
value: 'telemetry',
child: Row(
children: [
const Icon(Icons.bar_chart, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_telemetry),
],
),
),
PopupMenuItem(
value: 'settings',
child: Row(
children: [
const Icon(Icons.settings, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_settings),
],
),
),
PopupMenuItem(
value: 'clearChat',
child: Row(
children: [
const Icon(Icons.delete, size: 20, color: Colors.red),
const SizedBox(width: 12),
Text(
context.l10n.contact_clearChat,
style: const TextStyle(color: Colors.red),
),
],
),
),
],
);
},
), ),
], ],
), ),
@@ -251,7 +355,9 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.chat_sendMessageTo(widget.contact.name), context.l10n.chat_sendMessageTo(
_resolveContact(context.read<MeshCoreConnector>()).name,
),
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
], ],
@@ -269,6 +375,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Auto-scroll to bottom if user is already at bottom // Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollController.scrollToBottomIfAtBottom(); _scrollController.scrollToBottomIfAtBottom();
}); });
@@ -293,10 +400,10 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
final messageIndex = index; final messageIndex = index;
Contact contact = widget.contact; Contact contact = _resolveContact(connector);
final message = reversedMessages[messageIndex]; final message = reversedMessages[messageIndex];
String fourByteHex = ''; String fourByteHex = '';
if (widget.contact.type == advTypeRoom) { if (contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, connector,
message.fourByteRoomContactKey.isEmpty message.fourByteRoomContactKey.isEmpty
@@ -314,12 +421,13 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>( final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale, (service) => service.scale,
); );
final resolvedContact = _resolveContact(connector);
return _MessageBubble( return _MessageBubble(
message: message, message: message,
senderName: widget.contact.type == advTypeRoom senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]" ? "${contact.name} [$fourByteHex]"
: contact.name, : contact.name,
isRoomServer: widget.contact.type == advTypeRoom, isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale, textScale: textScale,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -457,7 +565,7 @@ class _ChatScreenState extends State<ChatScreen> {
return; return;
} }
connector.sendMessage(widget.contact, text); connector.sendMessage(_resolveContact(connector), text);
_textController.clear(); _textController.clear();
_textFieldFocusNode.requestFocus(); _textFieldFocusNode.requestFocus();
} }
@@ -654,7 +762,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Set the path override to persist user's choice // Set the path override to persist user's choice
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: pathLength, pathLen: pathLength,
pathBytes: pathBytes, pathBytes: pathBytes,
); );
@@ -663,7 +771,7 @@ class _ChatScreenState extends State<ChatScreen> {
Navigator.pop(context); Navigator.pop(context);
await _notifyPathSet( await _notifyPathSet(
connector, connector,
widget.contact, _resolveContact(connector),
pathBytes, pathBytes,
path.hopCount, path.hopCount,
); );
@@ -722,7 +830,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
), ),
onTap: () async { onTap: () async {
await connector.clearContactPath(widget.contact); await connector.clearContactPath(
_resolveContact(connector),
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -750,7 +860,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
onTap: () async { onTap: () async {
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: -1, pathLen: -1,
); );
if (!context.mounted) return; if (!context.mounted) return;
@@ -817,7 +927,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes), path: Uint8List.fromList(pathBytes),
flipPathRound: true, flipPathAround: true,
targetContact: widget.contact, targetContact: widget.contact,
), ),
), ),
@@ -833,11 +943,22 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
int _resolveContactIndex = -1;
Contact _resolveContact(MeshCoreConnector connector) { Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere( if (_resolveContactIndex >= 0 &&
_resolveContactIndex < connector.contacts.length &&
connector.contacts[_resolveContactIndex].publicKeyHex ==
widget.contact.publicKeyHex) {
return connector.contacts[_resolveContactIndex];
}
_resolveContactIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex, (c) => c.publicKeyHex == widget.contact.publicKeyHex,
orElse: () => widget.contact,
); );
if (_resolveContactIndex == -1) {
return widget.contact;
}
return connector.contacts[_resolveContactIndex];
} }
Contact _resolveContactFrom4Bytes( Contact _resolveContactFrom4Bytes(
@@ -890,19 +1011,11 @@ class _ChatScreenState extends State<ChatScreen> {
void _showContactInfo(BuildContext context) { void _showContactInfo(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex); final contact = _resolveContact(connector);
showDialog( showDialog(
context: context, context: context,
builder: (context) => Consumer<MeshCoreConnector>( builder: (context) => AlertDialog(
builder: (context, connector, _) { title: SelectableText(contact.name),
final contact = _resolveContact(connector);
final smazEnabled = connector.isContactSmazEnabled(
contact.publicKeyHex,
);
return AlertDialog(
title: Text(contact.name),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -910,16 +1023,54 @@ class _ChatScreenState extends State<ChatScreen> {
children: [ children: [
_buildInfoRow(context.l10n.chat_type, contact.typeLabel), _buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel), _buildInfoRow(context.l10n.chat_path, contact.pathLabel),
_buildInfoRow(
context.l10n.contact_lastSeen,
_formatContactLastMessage(contact.lastMessageAt),
),
if (contact.hasLocation) if (contact.hasLocation)
_buildInfoRow( _buildInfoRow(
context.l10n.chat_location, context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', '${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
), ),
_buildInfoRow( _buildInfoRow(context.l10n.chat_publicKey, contact.publicKeyHex),
context.l10n.chat_publicKey, ],
'${contact.publicKeyHex.substring(0, 16)}...',
), ),
const Divider(), ),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
),
],
),
);
}
void _showContactSettings(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
final contact = widget.contact;
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
bool teleBaseEnabled = contact.teleBaseEnabled;
bool teleLocEnabled = contact.teleLocEnabled;
bool teleEnvEnabled = contact.teleEnvEnabled;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(context.l10n.contact_settings),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.hasLocation) ...[
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
const Divider(height: 8),
],
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression), title: Text(context.l10n.channels_smazCompression),
@@ -930,6 +1081,37 @@ class _ChatScreenState extends State<ChatScreen> {
contact.publicKeyHex, contact.publicKeyHex,
value, value,
); );
setDialogState(() => smazEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleBase),
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
value: teleBaseEnabled,
onChanged: (value) {
setDialogState(() => teleBaseEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleLoc),
subtitle: Text(context.l10n.contact_teleLocSubtitle),
value: teleLocEnabled,
onChanged: (value) {
setDialogState(() => teleLocEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleEnv),
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
value: teleEnvEnabled,
onChanged: (value) {
setDialogState(() => teleEnvEnabled = value);
}, },
), ),
], ],
@@ -937,12 +1119,19 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () {
connector.setContactFlags(
contact,
teleBase: teleBaseEnabled,
teleLoc: teleLocEnabled,
teleEnv: teleEnvEnabled,
);
Navigator.pop(context);
},
child: Text(context.l10n.common_close), child: Text(context.l10n.common_close),
), ),
], ],
); ),
},
), ),
); );
} }
@@ -957,12 +1146,32 @@ class _ChatScreenState extends State<ChatScreen> {
width: 80, width: 80,
child: Text(label, style: TextStyle(color: Colors.grey[600])), child: Text(label, style: TextStyle(color: Colors.grey[600])),
), ),
Expanded(child: Text(value)), Expanded(child: SelectableText(value)),
], ],
), ),
); );
} }
String _formatContactLastMessage(DateTime timestamp) {
final diff = DateTime.now().difference(timestamp);
if (diff.isNegative || diff.inMinutes < 5) {
return context.l10n.contacts_lastSeenNow;
}
if (diff.inMinutes < 60) {
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
}
if (diff.inHours < 24) {
final hours = diff.inHours;
return hours == 1
? context.l10n.contacts_lastSeenHourAgo
: context.l10n.contacts_lastSeenHoursAgo(hours);
}
final days = diff.inDays;
return days == 1
? context.l10n.contacts_lastSeenDayAgo
: context.l10n.contacts_lastSeenDaysAgo(days);
}
void _openChat(BuildContext context, Contact contact) { void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater // Check if this is a repeater
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex); context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
@@ -986,7 +1195,7 @@ class _ChatScreenState extends State<ChatScreen> {
final currentPathLabel = _currentPathLabel(currentContact); final currentPathLabel = _currentPathLabel(currentContact);
// Filter out the current contact from available contacts // Filter out the current contact from available contacts
final availableContacts = connector.contacts final availableContacts = connector.allContacts
.where((c) => c != widget.contact) .where((c) => c != widget.contact)
.toList(); .toList();
@@ -1005,11 +1214,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
if (result == null) { if (result == null) {
appLogger.info( return; // Cancelled keep existing path
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
} }
if (!mounted) { if (!mounted) {
@@ -1025,14 +1230,19 @@ class _ChatScreenState extends State<ChatScreen> {
tag: 'ChatScreen', tag: 'ChatScreen',
); );
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: result.length, pathLen: result.length,
pathBytes: result, pathBytes: result,
); );
appLogger.info('setPathOverride completed', tag: 'ChatScreen'); appLogger.info('setPathOverride completed', tag: 'ChatScreen');
if (!mounted) return; if (!mounted) return;
await _notifyPathSet(connector, widget.contact, result, result.length); await _notifyPathSet(
connector,
_resolveContact(connector),
result,
result.length,
);
} }
void _openMessagePath(Message message, Contact contact) { void _openMessagePath(Message message, Contact contact) {
@@ -1044,10 +1254,10 @@ class _ChatScreenState extends State<ChatScreen> {
final String senderName; final String senderName;
if (message.isOutgoing) { if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me; senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) { } else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]"; senderName = "${contact.name} [$fourByteHex]";
} else { } else {
senderName = widget.contact.name; senderName = _resolveContact(connector).name;
} }
final pathMessage = ChannelMessage( final pathMessage = ChannelMessage(
senderKey: null, senderKey: null,
@@ -1110,7 +1320,8 @@ class _ChatScreenState extends State<ChatScreen> {
_retryMessage(message); _retryMessage(message);
}, },
), ),
if (widget.contact.type == advTypeRoom) if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
ListTile( ListTile(
leading: const Icon(Icons.chat), leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat), title: Text(context.l10n.contacts_openChat),
@@ -1148,7 +1359,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting // Retry using the contact's current path override setting
connector.sendMessage(widget.contact, message.text); connector.sendMessage(_resolveContact(connector), message.text);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage))); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
@@ -1174,7 +1385,8 @@ class _ChatScreenState extends State<ChatScreen> {
// For room servers, include sender name (like channels) since multiple users // For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null) // For 1:1 chats, sender is implicit (null)
final senderName = widget.contact.type == advTypeRoom final liveContact = _resolveContact(connector);
final senderName = liveContact.type == advTypeRoom
? senderContact.name ? senderContact.name
: null; : null;
final hash = ReactionHelper.computeReactionHash( final hash = ReactionHelper.computeReactionHash(
@@ -1183,7 +1395,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text, message.text,
); );
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText); connector.sendMessage(_resolveContact(connector), reactionText);
} }
} }
@@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
_isProcessing = true; _isProcessing = true;
}); });
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
try { try {
// Parse the community data // Parse the community data
final community = Community.fromQrData(const Uuid().v4(), data); final community = Community.fromQrData(const Uuid().v4(), data);
@@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
bool addPublicChannel, bool addPublicChannel,
) async { ) async {
// Save community to local storage // Save community to local storage
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
await _communityStore.addCommunity(community); await _communityStore.addCommunity(community);
// Optionally add the community public channel to the device // Optionally add the community public channel to the device
+416 -225
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/services/notification_service.dart';
import 'package:meshcore_open/utils/app_logger.dart'; import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -12,8 +13,9 @@ import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import '../storage/contact_group_store.dart'; import '../services/ui_view_state_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../storage/contact_group_store.dart';
import '../utils/dialog_utils.dart'; import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
@@ -47,12 +49,10 @@ class ContactsScreen extends StatefulWidget {
class _ContactsScreenState extends State<ContactsScreen> class _ContactsScreenState extends State<ContactsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen;
bool _showUnreadOnly = false;
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
final ContactGroupStore _groupStore = ContactGroupStore(); final ContactGroupStore _groupStore = ContactGroupStore();
MeshCoreConnector? _scopeSyncConnector;
List<ContactGroup> _groups = []; List<ContactGroup> _groups = [];
String _loadedGroupScopeKeyHex = '';
Timer? _searchDebounce; Timer? _searchDebounce;
final Set<ContactOperationType> _pendingOperations = {}; final Set<ContactOperationType> _pendingOperations = {};
@@ -62,8 +62,30 @@ class _ContactsScreenState extends State<ContactsScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController.text = context
.read<UiViewStateService>()
.contactsSearchText;
_loadGroups(); _loadGroups();
_setupFrameListener(); _setupFrameListener();
_clearAdvertNotifications();
}
void _clearAdvertNotifications() {
final connector = context.read<MeshCoreConnector>();
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
NotificationService().clearAdvertNotifications(contactIds);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final connector = context.read<MeshCoreConnector>();
if (!identical(_scopeSyncConnector, connector)) {
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
_scopeSyncConnector = connector;
_scopeSyncConnector?.addListener(_handleConnectorScopeChange);
}
_handleConnectorScopeChange();
} }
@override @override
@@ -71,21 +93,67 @@ class _ContactsScreenState extends State<ContactsScreen>
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_searchController.dispose(); _searchController.dispose();
_frameSubscription?.cancel(); _frameSubscription?.cancel();
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
super.dispose(); super.dispose();
} }
void _handleConnectorScopeChange() {
final connector = _scopeSyncConnector;
if (connector == null) return;
_syncGroupScopeIfNeeded(connector);
}
Future<void> _loadGroups() async { Future<void> _loadGroups() async {
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
if (selfPublicKeyHex.isEmpty) {
return;
}
_groupStore.setPublicKeyHex = selfPublicKeyHex;
final groups = await _groupStore.loadGroups(); final groups = await _groupStore.loadGroups();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_loadedGroupScopeKeyHex = selfPublicKeyHex;
_groups = groups; _groups = groups;
_ensureValidSelectedGroup();
}); });
} }
Future<void> _saveGroups() async { Future<void> _saveGroups() async {
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
if (selfPublicKeyHex.isEmpty) {
return;
}
_groupStore.setPublicKeyHex = selfPublicKeyHex;
await _groupStore.saveGroups(_groups); await _groupStore.saveGroups(_groups);
} }
bool _hasGroupStoreScope(MeshCoreConnector connector) {
return connector.selfPublicKeyHex.isNotEmpty;
}
void _syncGroupScopeIfNeeded(MeshCoreConnector connector) {
final selfPublicKeyHex = connector.selfPublicKeyHex;
if (selfPublicKeyHex.isEmpty ||
selfPublicKeyHex == _loadedGroupScopeKeyHex) {
return;
}
_loadGroups();
}
void _collapseContactsSearch(UiViewStateService viewState) {
_searchDebounce?.cancel();
_searchDebounce = null;
_searchController.clear();
viewState.setContactsSearchText('');
viewState.setContactsSearchExpanded(false);
}
void _showGroupsUnavailableMessage(BuildContext context) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.common_loading)));
}
void _setupFrameListener() { void _setupFrameListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater // Listen for incoming text messages from the repeater
@@ -375,31 +443,166 @@ class _ContactsScreenState extends State<ContactsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) { ContactGroup? _selectedGroupForName(String selectedGroupName) {
return ContactsFilterMenu( if (selectedGroupName == contactsAllGroupsValue) return null;
sortOption: _sortOption, for (final group in _groups) {
typeFilter: _typeFilter, if (group.name == selectedGroupName) return group;
showUnreadOnly: _showUnreadOnly, }
onSortChanged: (value) { return null;
setState(() { }
_sortOption = value;
void _ensureValidSelectedGroup() {
final viewState = context.read<UiViewStateService>();
if (viewState.contactsSelectedGroupName == contactsAllGroupsValue) return;
final exists = _groups.any(
(group) => group.name == viewState.contactsSelectedGroupName,
);
if (!exists) {
viewState.setContactsSelectedGroupName(contactsAllGroupsValue);
}
}
void _closeDropdownAndRun(BuildContext popupContext, VoidCallback action) {
final route = ModalRoute.of(popupContext);
if (route != null && route.isCurrent) {
Navigator.of(popupContext).pop();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
action();
}); });
}
Widget _buildFilterButton(
BuildContext context,
UiViewStateService viewState,
) {
return ContactsFilterMenu(
sortOption: viewState.contactsSortOption,
typeFilter: viewState.contactsTypeFilter,
showUnreadOnly: viewState.contactsShowUnreadOnly,
onSortChanged: (value) {
viewState.setContactsSortOption(value);
}, },
onTypeFilterChanged: (value) { onTypeFilterChanged: (value) {
setState(() { viewState.setContactsTypeFilter(value);
_typeFilter = value;
});
}, },
onUnreadOnlyChanged: (value) { onUnreadOnlyChanged: (value) {
setState(() { viewState.setContactsShowUnreadOnly(value);
_showUnreadOnly = value;
});
}, },
onNewGroup: () => _showGroupEditor(context, connector.contacts), );
}
Widget _buildGroupButton(
BuildContext context,
MeshCoreConnector connector,
UiViewStateService viewState,
List<Contact> contacts,
List<ContactGroup> sortedGroups,
) {
final canManageGroups = _hasGroupStoreScope(connector);
final selectedGroupName =
_selectedGroupForName(viewState.contactsSelectedGroupName)?.name ??
context.l10n.listFilter_all;
final double menuWidth = (MediaQuery.sizeOf(context).width - 16).clamp(
0.0,
double.infinity,
);
return PopupMenuButton<String>(
position: PopupMenuPosition.under,
constraints: BoxConstraints.tightFor(width: menuWidth),
onSelected: (String value) {
viewState.setContactsSelectedGroupName(value);
},
itemBuilder: (menuContext) => [
PopupMenuItem<String>(
value: contactsAllGroupsValue,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(menuContext.l10n.listFilter_all),
IconButton(
tooltip: menuContext.l10n.contacts_newGroup,
icon: const Icon(Icons.group_add, size: 20),
onPressed: canManageGroups
? () => _closeDropdownAndRun(
menuContext,
() => _showGroupEditor(this.context, contacts),
)
: () => _closeDropdownAndRun(
menuContext,
() => _showGroupsUnavailableMessage(this.context),
),
),
],
),
),
...sortedGroups.map((group) {
return PopupMenuItem<String>(
value: group.name,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(group.name, overflow: TextOverflow.ellipsis),
),
IconButton(
tooltip: menuContext.l10n.contacts_editGroup,
icon: const Icon(Icons.edit, size: 20),
onPressed: canManageGroups
? () => _closeDropdownAndRun(
menuContext,
() => _showGroupEditor(
this.context,
contacts,
group: group,
),
)
: () => _closeDropdownAndRun(
menuContext,
() => _showGroupsUnavailableMessage(this.context),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: menuContext.l10n.contacts_deleteGroup,
icon: const Icon(Icons.delete, size: 20, color: Colors.red),
onPressed: canManageGroups
? () => _closeDropdownAndRun(
menuContext,
() => _confirmDeleteGroup(this.context, group),
)
: () => _closeDropdownAndRun(
menuContext,
() => _showGroupsUnavailableMessage(this.context),
),
),
],
),
);
}),
],
child: SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(selectedGroupName, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_drop_down),
],
),
),
),
); );
} }
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
final viewState = context.watch<UiViewStateService>();
final contacts = connector.contacts; final contacts = connector.contacts;
final shouldShowStartupSpinner = final shouldShowStartupSpinner =
contacts.isEmpty && contacts.isEmpty &&
@@ -421,92 +624,171 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
final filteredAndSorted = _filterAndSortContacts(contacts, connector); final filteredAndSorted = _filterAndSortContacts(
final filteredGroups = _showUnreadOnly contacts,
? const <ContactGroup>[] connector,
: _filterAndSortGroups(_groups, contacts); viewState,
);
String hintText = ""; String hintText = "";
switch (_typeFilter) { switch (viewState.contactsTypeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
hintText = context.l10n.contacts_searchContacts( hintText = context.l10n.contacts_searchContacts(
filteredAndSorted.length, filteredAndSorted.length,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
case ContactTypeFilter.users: case ContactTypeFilter.users:
hintText = context.l10n.contacts_searchUsers( hintText = context.l10n.contacts_searchUsers(
filteredAndSorted.length, filteredAndSorted.length,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
case ContactTypeFilter.repeaters: case ContactTypeFilter.repeaters:
hintText = context.l10n.contacts_searchRepeaters( hintText = context.l10n.contacts_searchRepeaters(
filteredAndSorted.length, filteredAndSorted.length,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
case ContactTypeFilter.rooms: case ContactTypeFilter.rooms:
hintText = context.l10n.contacts_searchRoomServers( hintText = context.l10n.contacts_searchRoomServers(
filteredAndSorted.length, filteredAndSorted.length,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
case ContactTypeFilter.favorites: case ContactTypeFilter.favorites:
hintText = context.l10n.contacts_searchFavorites( hintText = context.l10n.contacts_searchFavorites(
filteredAndSorted.length, filteredAndSorted.length,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
} }
final groupsByName = <String, ContactGroup>{};
for (final group in _groups) {
groupsByName.putIfAbsent(group.name, () => group);
}
final sortedGroups = groupsByName.values.toList()
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
final screenWidth = MediaQuery.sizeOf(context).width;
final searchExpandedWidth = (screenWidth * 0.52).clamp(
97.0,
double.infinity,
); // allow expansion up to 52% of screen width, but not less than the collapsed width
final searchCollapsedWidth = (screenWidth * 0.22).clamp(
97.0,
120.0,
); //two 48px icon buttons + 1px divider
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: TextField( child: Row(
controller: _searchController,
decoration: InputDecoration(
hintText: hintText,
prefixIcon: const Icon(Icons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_searchQuery.isNotEmpty) Expanded(
IconButton( child: _buildGroupButton(
icon: const Icon(Icons.clear), context,
onPressed: () { connector,
_searchController.clear(); viewState,
setState(() { contacts,
_searchQuery = ''; sortedGroups,
});
},
), ),
_buildFilterButton(context, connector),
],
), ),
border: OutlineInputBorder( const SizedBox(width: 8),
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: viewState.contactsSearchExpanded
? searchExpandedWidth
: searchCollapsedWidth,
height: 48,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row(
children: [
Expanded(
child: viewState.contactsSearchExpanded
? TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: hintText,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 12,
vertical: 12, vertical: 10,
), ),
), ),
onChanged: (value) { onChanged: (value) {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () { _searchDebounce = Timer(
const Duration(milliseconds: 300),
() {
if (!mounted) return; if (!mounted) return;
setState(() { context
_searchQuery = value.toLowerCase(); .read<UiViewStateService>()
}); .setContactsSearchText(value);
});
}, },
);
},
)
: const SizedBox.shrink(),
),
SizedBox(
width: 48,
height: 48,
child: IconButton(
onPressed: () {
if (viewState.contactsSearchExpanded) {
_collapseContactsSearch(viewState);
return;
}
viewState.setContactsSearchExpanded(true);
},
icon: Icon(
viewState.contactsSearchExpanded
? Icons.close
: Icons.search,
),
),
),
Container(
width: 1,
height: 24,
color: Theme.of(context).colorScheme.outlineVariant,
),
SizedBox(
width: 48,
height: 48,
child: _buildFilterButton(context, viewState),
),
],
),
),
),
],
), ),
), ),
Expanded( Expanded(
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty child: filteredAndSorted.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -514,7 +796,7 @@ class _ContactsScreenState extends State<ContactsScreen>
Icon(Icons.search_off, size: 64, color: Colors.grey[400]), Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_showUnreadOnly viewState.contactsShowUnreadOnly
? context.l10n.contacts_noUnreadContacts ? context.l10n.contacts_noUnreadContacts
: context.l10n.contacts_noContactsFound, : context.l10n.contacts_noContactsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(fontSize: 16, color: Colors.grey[600]),
@@ -525,14 +807,9 @@ class _ContactsScreenState extends State<ContactsScreen>
: RefreshIndicator( : RefreshIndicator(
onRefresh: () => connector.getContacts(), onRefresh: () => connector.getContacts(),
child: ListView.builder( child: ListView.builder(
itemCount: filteredGroups.length + filteredAndSorted.length, itemCount: filteredAndSorted.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index < filteredGroups.length) { final contact = filteredAndSorted[index];
final group = filteredGroups[index];
return _buildGroupTile(context, group, contacts);
}
final contact =
filteredAndSorted[index - filteredGroups.length];
final unreadCount = connector.getUnreadCountForContact( final unreadCount = connector.getUnreadCountForContact(
contact, contact,
); );
@@ -553,55 +830,26 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
List<ContactGroup> _filterAndSortGroups(
List<ContactGroup> groups,
List<Contact> contacts,
) {
final query = _searchQuery.trim().toLowerCase();
final contactsByKey = <String, Contact>{};
for (final contact in contacts) {
contactsByKey[contact.publicKeyHex] = contact;
}
final filtered = groups
.where((group) {
if (query.isEmpty) return true;
if (group.name.toLowerCase().contains(query)) return true;
for (final key in group.memberKeys) {
final contact = contactsByKey[key];
if (contact != null && matchesContactQuery(contact, query)) {
return true;
}
}
return false;
})
.where((group) {
if (_typeFilter == ContactTypeFilter.all) return true;
// Groups don't have a favorite flag, so hide them under favorites filter
if (_typeFilter == ContactTypeFilter.favorites) return false;
for (final key in group.memberKeys) {
final contact = contactsByKey[key];
if (contact != null && _matchesTypeFilter(contact)) return true;
}
return false;
})
.toList();
filtered.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
return filtered;
}
List<Contact> _filterAndSortContacts( List<Contact> _filterAndSortContacts(
List<Contact> contacts, List<Contact> contacts,
MeshCoreConnector connector, MeshCoreConnector connector,
UiViewStateService viewState,
) { ) {
var filtered = contacts.where((contact) { var filtered = contacts.where((contact) {
if (_searchQuery.isEmpty) return true; if (viewState.contactsSearchText.isEmpty) return true;
return matchesContactQuery(contact, _searchQuery); return matchesContactQuery(contact, viewState.contactsSearchText);
}).toList(); }).toList();
final selectedGroup = _selectedGroupForName(
viewState.contactsSelectedGroupName,
);
if (selectedGroup != null) {
final memberKeys = selectedGroup.memberKeys.toSet();
filtered = filtered
.where((contact) => memberKeys.contains(contact.publicKeyHex))
.toList();
}
// Filter out own node from the list // Filter out own node from the list
if (connector.selfPublicKey != null) { if (connector.selfPublicKey != null) {
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!); final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
@@ -610,17 +858,22 @@ class _ContactsScreenState extends State<ContactsScreen>
}).toList(); }).toList();
} }
if (_typeFilter != ContactTypeFilter.all) { if (viewState.contactsTypeFilter != ContactTypeFilter.all) {
filtered = filtered.where(_matchesTypeFilter).toList(); filtered = filtered
.where(
(contact) =>
_matchesTypeFilter(contact, viewState.contactsTypeFilter),
)
.toList();
} }
if (_showUnreadOnly) { if (viewState.contactsShowUnreadOnly) {
filtered = filtered.where((contact) { filtered = filtered.where((contact) {
return connector.getUnreadCountForContact(contact) > 0; return connector.getUnreadCountForContact(contact) > 0;
}).toList(); }).toList();
} }
switch (_sortOption) { switch (viewState.contactsSortOption) {
case ContactSortOption.lastSeen: case ContactSortOption.lastSeen:
filtered.sort( filtered.sort(
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)), (a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
@@ -649,8 +902,8 @@ class _ContactsScreenState extends State<ContactsScreen>
return filtered; return filtered;
} }
bool _matchesTypeFilter(Contact contact) { bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) {
switch (_typeFilter) { switch (typeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
return true; return true;
case ContactTypeFilter.favorites: case ContactTypeFilter.favorites:
@@ -671,57 +924,6 @@ class _ContactsScreenState extends State<ContactsScreen>
: contact.lastSeen; : contact.lastSeen;
} }
Widget _buildGroupTile(
BuildContext context,
ContactGroup group,
List<Contact> contacts,
) {
final memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(context, memberContacts);
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.group, color: Colors.white, size: 20),
),
title: Text(group.name),
subtitle: Text(subtitle),
trailing: Text(
memberContacts.length.toString(),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
onTap: () => _showGroupOptions(context, group, contacts),
onLongPress: () => _showGroupOptions(context, group, contacts),
);
}
List<Contact> _resolveGroupContacts(
ContactGroup group,
List<Contact> contacts,
) {
final byKey = <String, Contact>{};
for (final contact in contacts) {
byKey[contact.publicKeyHex] = contact;
}
final resolved = <Contact>[];
for (final key in group.memberKeys) {
final contact = byKey[key];
if (contact != null) {
resolved.add(contact);
}
}
resolved.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
return resolved;
}
String _formatGroupMembers(BuildContext context, List<Contact> members) {
if (members.isEmpty) return context.l10n.contacts_noMembers;
final names = members.map((c) => c.name).toList();
if (names.length <= 2) return names.join(', ');
return '${names.take(2).join(', ')} +${names.length - 2}';
}
void _openChat(BuildContext context, Contact contact) { void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater // Check if this is a repeater
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
@@ -799,58 +1001,11 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
void _showGroupOptions(
BuildContext context,
ContactGroup group,
List<Contact> contacts,
) {
final members = _resolveGroupContacts(group, contacts);
showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: Text(context.l10n.contacts_editGroup),
onTap: () {
Navigator.pop(sheetContext);
_showGroupEditor(context, contacts, group: group);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
context.l10n.contacts_deleteGroup,
style: const TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(sheetContext);
_confirmDeleteGroup(context, group);
},
),
if (members.isNotEmpty) const Divider(),
...members.map((member) {
return ListTile(
leading: const Icon(Icons.person),
title: Text(member.name),
subtitle: Text(member.typeLabel),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, member);
},
);
}),
],
),
),
),
);
}
void _confirmDeleteGroup(BuildContext context, ContactGroup group) { void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
_showGroupsUnavailableMessage(context);
return;
}
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -866,6 +1021,7 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
setState(() { setState(() {
_groups.removeWhere((g) => g.name == group.name); _groups.removeWhere((g) => g.name == group.name);
_ensureValidSelectedGroup();
}); });
await _saveGroups(); await _saveGroups();
}, },
@@ -884,6 +1040,10 @@ class _ContactsScreenState extends State<ContactsScreen>
List<Contact> contacts, { List<Contact> contacts, {
ContactGroup? group, ContactGroup? group,
}) { }) {
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
_showGroupsUnavailableMessage(context);
return;
}
final isEditing = group != null; final isEditing = group != null;
final nameController = TextEditingController(text: group?.name ?? ''); final nameController = TextEditingController(text: group?.name ?? '');
final selectedKeys = <String>{...group?.memberKeys ?? []}; final selectedKeys = <String>{...group?.memberKeys ?? []};
@@ -910,6 +1070,10 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
content: SizedBox( content: SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -935,8 +1099,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( Expanded(
height: 240,
child: filteredContacts.isEmpty child: filteredContacts.isEmpty
? Center( ? Center(
child: Text( child: Text(
@@ -959,7 +1122,9 @@ class _ContactsScreenState extends State<ContactsScreen>
if (value == true) { if (value == true) {
selectedKeys.add(contact.publicKeyHex); selectedKeys.add(contact.publicKeyHex);
} else { } else {
selectedKeys.remove(contact.publicKeyHex); selectedKeys.remove(
contact.publicKeyHex,
);
} }
}); });
}, },
@@ -970,6 +1135,7 @@ class _ContactsScreenState extends State<ContactsScreen>
], ],
), ),
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
@@ -986,6 +1152,15 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
return; return;
} }
if (name.toLowerCase() ==
contactsAllGroupsValue.toLowerCase()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_groupNameReserved),
),
);
return;
}
final exists = _groups.any((g) { final exists = _groups.any((g) {
if (isEditing && g.name == group.name) return false; if (isEditing && g.name == group.name) return false;
return g.name.toLowerCase() == name.toLowerCase(); return g.name.toLowerCase() == name.toLowerCase();
@@ -1001,15 +1176,21 @@ class _ContactsScreenState extends State<ContactsScreen>
return; return;
} }
setState(() { setState(() {
final viewState = context.read<UiViewStateService>();
if (isEditing) { if (isEditing) {
final index = _groups.indexWhere( final index = _groups.indexWhere(
(g) => g.name == group.name, (g) => g.name == group.name,
); );
if (index != -1) { if (index != -1) {
final wasSelected =
viewState.contactsSelectedGroupName == group.name;
_groups[index] = ContactGroup( _groups[index] = ContactGroup(
name: name, name: name,
memberKeys: selectedKeys.toList(), memberKeys: selectedKeys.toList(),
); );
if (wasSelected) {
viewState.setContactsSelectedGroupName(name);
}
} }
} else { } else {
_groups.add( _groups.add(
@@ -1018,7 +1199,9 @@ class _ContactsScreenState extends State<ContactsScreen>
memberKeys: selectedKeys.toList(), memberKeys: selectedKeys.toList(),
), ),
); );
viewState.setContactsSelectedGroupName(name);
} }
_ensureValidSelectedGroup();
}); });
await _saveGroups(); await _saveGroups();
if (dialogContext.mounted) { if (dialogContext.mounted) {
@@ -1056,7 +1239,7 @@ class _ContactsScreenState extends State<ContactsScreen>
if (isRepeater) ...[ if (isRepeater) ...[
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0 title: contact.pathBytesForDisplay.isNotEmpty
? Text(context.l10n.contacts_pathTrace) ? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping), : Text(context.l10n.contacts_ping),
onTap: () { onTap: () {
@@ -1064,10 +1247,12 @@ class _ContactsScreenState extends State<ContactsScreen>
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0 title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_repeaterPathTrace ? context.l10n.contacts_repeaterPathTrace
: context.l10n.contacts_repeaterPing, : context.l10n.contacts_repeaterPing,
path: contact.traceRouteBytes ?? Uint8List(0), path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
), ),
), ),
); );
@@ -1092,10 +1277,12 @@ class _ContactsScreenState extends State<ContactsScreen>
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0 title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_roomPathTrace ? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing, : context.l10n.contacts_roomPing,
path: contact.traceRouteBytes ?? Uint8List(0), path: contact.pathBytesForDisplay,
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact,
), ),
), ),
); );
@@ -1137,7 +1324,8 @@ class _ContactsScreenState extends State<ContactsScreen>
title: context.l10n.contacts_pathTraceTo( title: context.l10n.contacts_pathTraceTo(
contact.name, contact.name,
), ),
path: contact.traceRouteBytes ?? Uint8List(0), path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact, targetContact: contact,
), ),
), ),
@@ -1165,7 +1353,10 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
onTap: () async { onTap: () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await connector.setContactFavorite(contact, !isFavorite); await connector.setContactFlags(
contact,
isFavorite: !isFavorite,
);
}, },
), ),
ListTile( ListTile(
+8 -7
View File
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/discovery_contact.dart'; import '../models/contact.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../widgets/app_bar.dart'; import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
@@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
Future<void> _showContactContextMenu( Future<void> _showContactContextMenu(
DiscoveryContact contact, Contact contact,
MeshCoreConnector connector, MeshCoreConnector connector,
) async { ) async {
final action = await showModalBottomSheet<String>( final action = await showModalBottomSheet<String>(
@@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
connector.importDiscoveredContact(contact); connector.importDiscoveredContact(contact);
break; break;
case 'copy_contact': case 'copy_contact':
final hexString = pubKeyToHex(contact.rawPacket); if (contact.rawPacket == null) return;
final hexString = pubKeyToHex(contact.rawPacket!);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
Widget _buildFilters( Widget _buildFilters(
List<DiscoveryContact> filteredAndSorted, List<Contact> filteredAndSorted,
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
String hintText = ""; String hintText = "";
@@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
); );
} }
List<DiscoveryContact> _filterAndSortContacts( List<Contact> _filterAndSortContacts(
List<DiscoveryContact> contacts, List<Contact> contacts,
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
var filtered = contacts.where((contact) { var filtered = contacts.where((contact) {
@@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return filtered; return filtered;
} }
bool _matchesTypeFilter(DiscoveryContact contact) { bool _matchesTypeFilter(Contact contact) {
switch (typeFilter) { switch (typeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
return true; return true;
+98 -10
View File
@@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget {
} }
class _MapScreenState extends State<MapScreen> { class _MapScreenState extends State<MapScreen> {
static const double _labelZoomThreshold = 8.5; // Zoom level at which node labels start to appear
static const double _labelZoomThreshold = 12.0;
final MapController _mapController = MapController(); final MapController _mapController = MapController();
final MapMarkerService _markerService = MapMarkerService(); final MapMarkerService _markerService = MapMarkerService();
@@ -91,6 +93,15 @@ class _MapScreenState extends State<MapScreen> {
}); });
} }
bool _checkLocationPlausibility(double lat, double lon) {
const double epsilon = 1e-6;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
double _standardDeviation(List<double> values) { double _standardDeviation(List<double> values) {
if (values.length <= 1) { if (values.length <= 1) {
return 0.0; return 0.0;
@@ -126,7 +137,12 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) { builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings; final settings = settingsService.settings;
final contacts = connector.contacts; final allContacts = connector.allContacts;
final contacts = settings.mapShowDiscoveryContacts
? allContacts
: allContacts.where((c) => c.isActive).toList();
final highlightPosition = widget.highlightPosition; final highlightPosition = widget.highlightPosition;
final sharedMarkers = settings.mapShowMarkers final sharedMarkers = settings.mapShowMarkers
? _collectSharedMarkers(connector) ? _collectSharedMarkers(connector)
@@ -159,13 +175,13 @@ class _MapScreenState extends State<MapScreen> {
: filteredByTime; : filteredByTime;
// Filter by location // Filter by location
final contactsWithLocation = filteredByKeyPrefix final contactsWithLocation = filteredByKeyPrefix.where((c) {
.where((c) => c.hasLocation) return c.hasLocation;
.toList(); }).toList();
// All contacts with a known location used as anchors regardless of // All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available. // time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = contacts final allContactsWithLocation = allContacts
.where((c) => c.hasLocation) .where((c) => c.hasLocation)
.toList(); .toList();
@@ -468,7 +484,10 @@ class _MapScreenState extends State<MapScreen> {
), ),
), ),
if (!_isBuildingPathTrace) if (!_isBuildingPathTrace)
...guessedLocations.map(_buildGuessedMarker), ..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
),
..._buildMarkers( ..._buildMarkers(
contactsWithLocation, contactsWithLocation,
settings, settings,
@@ -630,6 +649,13 @@ class _MapScreenState extends State<MapScreen> {
anchors[0].latitude + offsetDeg * cos(angle), anchors[0].latitude + offsetDeg * cos(angle),
anchors[0].longitude + offsetDeg * sin(angle), anchors[0].longitude + offsetDeg * sin(angle),
); );
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0)
}
} else { } else {
double lat = 0, lon = 0; double lat = 0, lon = 0;
for (final a in anchors) { for (final a in anchors) {
@@ -637,6 +663,12 @@ class _MapScreenState extends State<MapScreen> {
lon += a.longitude; lon += a.longitude;
} }
position = LatLng(lat / anchors.length, lon / anchors.length); position = LatLng(lat / anchors.length, lon / anchors.length);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0
}
} }
result.add( result.add(
_GuessedLocation( _GuessedLocation(
@@ -710,9 +742,15 @@ class _MapScreenState extends State<MapScreen> {
.toList(); .toList();
} }
Marker _buildGuessedMarker(_GuessedLocation guess) { List<Marker> _buildGuessedMarker(
List<_GuessedLocation> guessed, {
required bool showLabels,
}) {
final markers = <Marker>[];
for (final guess in guessed) {
final color = _getNodeColor(guess.contact.type); final color = _getNodeColor(guess.contact.type);
return Marker( final marker = Marker(
point: guess.position, point: guess.position,
width: 35, width: 35,
height: 35, height: 35,
@@ -725,7 +763,9 @@ class _MapScreenState extends State<MapScreen> {
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30), color: color.withValues(
alpha: guess.highConfidence ? 0.55 : 0.30,
),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2), border: Border.all(color: Colors.white, width: 2),
boxShadow: [ boxShadow: [
@@ -744,6 +784,19 @@ class _MapScreenState extends State<MapScreen> {
), ),
), ),
); );
markers.add(marker);
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: guess.position,
label: guess.contact.name,
),
);
}
}
return markers;
} }
List<Marker> _buildMarkers( List<Marker> _buildMarkers(
@@ -1203,6 +1256,7 @@ class _MapScreenState extends State<MapScreen> {
Contact contact, { Contact contact, {
LatLng? guessedPosition, LatLng? guessedPosition,
}) { }) {
final connector = context.read<MeshCoreConnector>();
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -1248,6 +1302,9 @@ class _MapScreenState extends State<MapScreen> {
advTypeChat) // Only show chat button for chat nodes advTypeChat) // Only show chat button for chat nodes
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
Navigator.push( Navigator.push(
context, context,
@@ -1261,6 +1318,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRepeater) if (contact.type == advTypeRepeater)
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
_showRepeaterLogin(context, contact); _showRepeaterLogin(context, contact);
}, },
@@ -1269,6 +1329,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRoom) if (contact.type == advTypeRoom)
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
_showRoomLogin(context, contact); _showRoomLogin(context, contact);
}, },
@@ -1436,6 +1499,23 @@ class _MapScreenState extends State<MapScreen> {
); );
}, },
), ),
ListTile(
leading: const Icon(Icons.my_location),
title: Text(context.l10n.map_setAsMyLocation),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final successMsg = context.l10n.settings_locationUpdated;
Navigator.pop(sheetContext);
if (!connector.isConnected) return;
await connector.setNodeLocation(
lat: position.latitude,
lon: position.longitude,
);
await connector.refreshDeviceInfo();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
},
),
ListTile( ListTile(
leading: const Icon(Icons.close), leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel), title: Text(context.l10n.common_cancel),
@@ -1745,6 +1825,14 @@ class _MapScreenState extends State<MapScreen> {
}, },
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
CheckboxListTile(
title: Text(context.l10n.map_showDiscoveryContacts),
value: settings.mapShowDiscoveryContacts,
onChanged: (value) {
service.setMapShowDiscoveryContacts(value ?? true);
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
context.l10n.map_keyPrefix, context.l10n.map_keyPrefix,
+20 -10
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();
@@ -124,12 +142,11 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
final contacts = connector.allContacts;
try { try {
final neighborCount = buffer.readUInt16LE(); final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
repeater,
) {
for (var neighborData in parsedNeighbors) { for (var neighborData in parsedNeighbors) {
final publicKey = neighborData['publicKey']; final publicKey = neighborData['publicKey'];
if (listEquals( if (listEquals(
@@ -164,13 +181,6 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
} }
} }
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
Future<void> _loadNeighbors() async { Future<void> _loadNeighbors() async {
if (_commandService == null) return; if (_commandService == null) return;
+109 -35
View File
@@ -52,8 +52,8 @@ class PathTraceMapScreen extends StatefulWidget {
final String title; final String title;
final Uint8List path; final Uint8List path;
final int? repeaterId; final int? repeaterId;
final bool flipPathRound; final bool flipPathAround;
final bool reversePathRound; final bool reversePathAround;
final Contact? targetContact; final Contact? targetContact;
const PathTraceMapScreen({ const PathTraceMapScreen({
@@ -61,8 +61,8 @@ class PathTraceMapScreen extends StatefulWidget {
required this.title, required this.title,
required this.path, required this.path,
this.repeaterId, this.repeaterId,
this.flipPathRound = false, this.flipPathAround = false,
this.reversePathRound = false, this.reversePathAround = false,
this.targetContact, this.targetContact,
}); });
@@ -93,6 +93,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
ValueKey<String> _mapKey = const ValueKey('initial'); ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistanceMeters = 0.0; double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true; bool _showNodeLabels = true;
Contact? _targetContact;
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes return pathBytes
@@ -114,8 +115,30 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
super.dispose(); super.dispose();
} }
Uint8List addReturnPath(Uint8List pathBytes) { Uint8List buildPath(Uint8List pathBytes) {
Uint8List? traceBytes; Uint8List traceBytes;
if (pathBytes.isEmpty) {
traceBytes = Uint8List(1);
traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
return traceBytes;
}
if (widget.targetContact?.type == advTypeRepeater ||
widget.targetContact?.type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1); final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len); traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) { for (int i = 0; i < pathBytes.length; i++) {
@@ -124,6 +147,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
traceBytes[len - 1 - i] = pathBytes[i]; traceBytes[len - 1 - i] = pathBytes[i];
} }
} }
}
return traceBytes; return traceBytes;
} }
@@ -135,17 +159,17 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
}); });
} }
final Uint8List path; final pathTmp = widget.reversePathAround
Uint8List pathTmp = widget.reversePathRound
? Uint8List.fromList(widget.path.reversed.toList()) ? Uint8List.fromList(widget.path.reversed.toList())
: widget.path; : widget.path;
if (widget.flipPathRound) { final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
path = addReturnPath(pathTmp);
} else { appLogger.info(
path = pathTmp; 'Initiating path trace with path: ${_formatPathPrefixes(path)}',
} tag: 'PathTraceMapScreen',
noNotify: !mounted,
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq( final frame = buildTraceReq(
@@ -235,10 +259,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList(); .toList();
Map<int, Contact> pathContacts = {}; Map<int, Contact> pathContacts = {};
final contacts = connector.allContacts;
connector.contacts.where((c) => c.type != advTypeChat).forEach(( contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
repeater,
) {
for (var repeaterData in pathData) { for (var repeaterData in pathData) {
if (listEquals( if (listEquals(
repeater.publicKey.sublist(0, 1), repeater.publicKey.sublist(0, 1),
@@ -283,18 +305,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Compute endpoint position for the target contact. // Compute endpoint position for the target contact.
LatLng? targetPos; LatLng? targetPos;
bool targetGuessed = false; bool targetGuessed = false;
final target = widget.targetContact; _targetContact = widget.targetContact;
if (target != null) {
if (target.hasLocation) { if (_targetContact != null) {
targetPos = LatLng(target.latitude!, target.longitude!); final tc = _targetContact!;
} else if (pathData.isNotEmpty) { if (tc.hasLocation) {
targetPos = LatLng(tc.latitude!, tc.longitude!);
} else if (widget.path.length > 1) {
// Infer from the last hop: average GPS contacts sharing that hop. // Infer from the last hop: average GPS contacts sharing that hop.
// For a round-trip path (flipPathRound), the target-side hop sits // For a round-trip path (flipPathAround/reversePathAround), the target-side hop
// in the middle of the symmetric sequence; .last is the local side. // sits in the middle of the symmetric sequence; .last is the local side.
final lastHop = (widget.flipPathRound && pathData.length > 1) final lastHop = widget.reversePathAround
? pathData[(pathData.length - 1) ~/ 2] ? widget.path.first
: pathData.last; : widget.path.last;
final peers = connector.contacts
final peers = connector.allContacts
.where( .where(
(c) => (c) =>
c.hasLocation && c.hasLocation &&
@@ -310,12 +335,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
peers.length; peers.length;
const offsetDeg = 0.003; const offsetDeg = 0.003;
final angle = (target.publicKey[1] / 255.0) * 2 * pi; final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng( targetPos = LatLng(
lat + offsetDeg * cos(angle), lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle), lon + offsetDeg * sin(angle),
); );
targetGuessed = true; targetGuessed = true;
} else if (inferredPositions.containsKey(lastHop)) {
final lat = inferredPositions[lastHop]!.latitude;
final lon = inferredPositions[lastHop]!.longitude;
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else {
// As a last resort, just place it at the same position as the last hop.
final contact = pathContacts[lastHop];
if (contact != null && contact.hasLocation) {
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
contact.latitude! + offsetDeg * cos(angle),
contact.longitude! + offsetDeg * sin(angle),
);
targetGuessed = true;
}
} }
} }
} }
@@ -324,7 +371,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_points = <LatLng>[]; _points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
int hopLast = 0;
int hopLastLast = 0;
for (final hop in _traceData!.pathData) { for (final hop in _traceData!.pathData) {
if (hop == hopLastLast && widget.flipPathAround) {
break; //skip duplicate hops in round-trip paths
}
final contact = _traceData!.pathContacts[hop]; final contact = _traceData!.pathContacts[hop];
if (contact != null && contact.hasLocation) { if (contact != null && contact.hasLocation) {
_points.add(LatLng(contact.latitude!, contact.longitude!)); _points.add(LatLng(contact.latitude!, contact.longitude!));
@@ -332,8 +384,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final inferred = inferredPositions[hop]; final inferred = inferredPositions[hop];
if (inferred != null) _points.add(inferred); if (inferred != null) _points.add(inferred);
} }
hopLastLast = hopLast;
hopLast = hop;
}
if (targetPos != null) {
if (_targetContact != null && _targetContact!.type == advTypeChat) {
_points.add(targetPos);
}
} }
if (targetPos != null) _points.add(targetPos);
_polylines = _points.length > 1 _polylines = _points.length > 1
? [ ? [
Polyline( Polyline(
@@ -422,7 +480,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
], ],
), ),
), ),
if (_hasData) _buildMapPathTrace(context, tileCache), if (_hasData)
_buildMapPathTrace(context, tileCache, _targetContact),
if (_points.isEmpty && if (_points.isEmpty &&
!_hasData && !_hasData &&
!_isLoading && !_isLoading &&
@@ -451,17 +510,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
List<Marker> _buildHopMarkers( List<Marker> _buildHopMarkers(
List<int> pathData, { List<int> pathData, {
required bool showLabels, required bool showLabels,
required Contact? target,
}) { }) {
final markers = <Marker>[]; final markers = <Marker>[];
int hopLast = 0;
int hopLastLast = 0;
for (final hop in pathData) { for (final hop in pathData) {
final contact = _traceData!.pathContacts[hop]; final contact = _traceData!.pathContacts[hop];
final inferred = _inferredHopPositions[hop]; final inferred = _inferredHopPositions[hop];
final hasGps = contact != null && contact.hasLocation; final hasGps = contact != null && contact.hasLocation;
if (!hasGps && inferred == null) continue; if (hop == hopLastLast && widget.flipPathAround) {
continue; //skip duplicate hops in round-trip paths
}
if (!hasGps && inferred == null) {
hopLastLast = hopLast;
hopLast = hop;
continue; //skip hops with no GPS and no inferred position
}
final point = hasGps final point = hasGps
? LatLng(contact.latitude!, contact.longitude!) ? LatLng(contact.latitude!, contact.longitude!)
: inferred!; : inferred!;
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
markers.add( markers.add(
Marker( Marker(
point: point, point: point,
@@ -503,6 +573,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
); );
} }
hopLastLast = hopLast;
hopLast = hop;
} }
final selfLat = context.read<MeshCoreConnector>().selfLatitude; final selfLat = context.read<MeshCoreConnector>().selfLatitude;
@@ -552,9 +624,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Add target contact endpoint marker. // Add target contact endpoint marker.
final targetPos = _targetContactPosition; final targetPos = _targetContactPosition;
if (targetPos != null) { if (targetPos != null && target != null && target.type == advTypeChat) {
final isGuessed = _targetContactIsGuessed; final isGuessed = _targetContactIsGuessed;
final targetName = widget.targetContact?.name ?? '?'; final targetName = target.name;
markers.add( markers.add(
Marker( Marker(
point: targetPos, point: targetPos,
@@ -690,6 +762,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Widget _buildMapPathTrace( Widget _buildMapPathTrace(
BuildContext context, BuildContext context,
MapTileCacheService tileCache, MapTileCacheService tileCache,
Contact? target,
) { ) {
return FlutterMap( return FlutterMap(
key: _mapKey, key: _mapKey,
@@ -728,6 +801,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers: _buildHopMarkers( markers: _buildHopMarkers(
_traceData!.pathData, _traceData!.pathData,
showLabels: _showNodeLabels, showLabels: _showNodeLabels,
target: target,
), ),
), ),
], ],
+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) {
+18
View File
@@ -10,6 +10,7 @@ import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'tcp_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
/// Screen for scanning and connecting to MeshCore devices /// Screen for scanning and connecting to MeshCore devices
@@ -125,10 +126,14 @@ class _ScannerScreenState extends State<ScannerScreen> {
connector.state == MeshCoreConnectionState.scanning; connector.state == MeshCoreConnectionState.scanning;
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
final usbSupported = PlatformInfo.supportsUsbSerial; final usbSupported = PlatformInfo.supportsUsbSerial;
final tcpSupported = !PlatformInfo.isWeb;
return SafeArea( return SafeArea(
top: false, top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@@ -148,6 +153,18 @@ class _ScannerScreenState extends State<ScannerScreen> {
label: Text(context.l10n.connectionChoiceUsbLabel), label: Text(context.l10n.connectionChoiceUsbLabel),
), ),
if (usbSupported) const SizedBox(width: 12), if (usbSupported) const SizedBox(width: 12),
if (tcpSupported)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'scanner_tcp_action',
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (tcpSupported) const SizedBox(width: 12),
FloatingActionButton.extended( FloatingActionButton.extended(
heroTag: 'scanner_ble_action', heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff onPressed: isBluetoothOff
@@ -181,6 +198,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
), ),
], ],
), ),
),
); );
}, },
), ),
+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;
+295
View File
@@ -0,0 +1,295 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart';
import 'usb_screen.dart';
class TcpScreen extends StatefulWidget {
const TcpScreen({super.key});
@override
State<TcpScreen> createState() => _TcpScreenState();
}
class _TcpScreenState extends State<TcpScreen> {
late final TextEditingController _hostController;
late final TextEditingController _portController;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
bool _navigatedToContacts = false;
@override
void initState() {
super.initState();
_hostController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerAddress,
);
_portController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerPort > 0
? context.read<AppSettingsService>().settings.tcpServerPort.toString()
: '',
);
_connector = context.read<MeshCoreConnector>();
_connectionListener = () {
if (!mounted) return;
if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false;
}
if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isTcpTransportConnected &&
!_navigatedToContacts) {
context.read<AppSettingsService>().setTcpServerAddress(
_hostController.text,
);
context.read<AppSettingsService>().setTcpServerPort(
int.tryParse(_portController.text) ?? 0,
);
_navigatedToContacts = true;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
}
};
_connector.addListener(_connectionListener);
}
@override
void dispose() {
_hostController.dispose();
_portController.dispose();
_connector.removeListener(_connectionListener);
if (!_navigatedToContacts &&
_connector.activeTransport == MeshCoreTransportType.tcp &&
_connector.state != MeshCoreConnectionState.disconnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_connector.disconnect(manual: true));
});
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: AdaptiveAppBarTitle(context.l10n.tcpScreenTitle),
centerTitle: true,
),
body: SafeArea(
top: false,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp;
final isButtonDisabled =
isConnecting ||
connector.state == MeshCoreConnectionState.scanning;
return Column(
children: [
_buildStatusBar(context, connector),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _hostController,
decoration: InputDecoration(
labelText: context.l10n.tcpHostLabel,
hintText: context.l10n.tcpHostHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.url,
),
const SizedBox(height: 12),
TextField(
controller: _portController,
decoration: InputDecoration(
labelText: context.l10n.tcpPortLabel,
hintText: context.l10n.tcpPortHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
FilledButton.icon(
key: const Key('tcp_connect_button'),
onPressed: isButtonDisabled ? null : _connectTcp,
icon: isConnecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.lan),
label: Text(
isConnecting
? context.l10n.scanner_connecting
: context.l10n.common_connect,
),
),
],
),
),
],
);
},
),
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (PlatformInfo.supportsUsbSerial)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'tcp_usb_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).maybePop();
},
heroTag: 'tcp_ble_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
],
),
),
),
);
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
String statusText;
Color statusColor;
if (connector.isTcpTransportConnected) {
statusText = l10n.scanner_connectedTo(
connector.activeTcpEndpoint ?? 'TCP',
);
statusColor = Colors.green;
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.tcpStatus_connectingTo(
'${_hostController.text}:${_portController.text}',
);
statusColor = Colors.orange;
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
} else {
statusText = l10n.tcpStatus_notConnected;
statusColor = Colors.grey;
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
Future<void> _connectTcp() async {
if (_connector.state == MeshCoreConnectionState.connecting ||
_connector.state == MeshCoreConnectionState.connected ||
_connector.state == MeshCoreConnectionState.disconnecting) {
return;
}
final host = _hostController.text.trim();
final parsedPort = int.tryParse(_portController.text.trim());
if (host.isEmpty) {
_showError(context.l10n.tcpErrorHostRequired);
return;
}
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
_showError(context.l10n.tcpErrorPortInvalid);
return;
}
try {
await _connector.connectTcp(host: host, port: parsedPort);
} catch (error) {
if (!mounted) return;
_showError(_friendlyErrorMessage(error));
}
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
}
String _friendlyErrorMessage(Object error) {
if (error is UnsupportedError) {
return context.l10n.tcpErrorUnsupported;
}
if (error is TimeoutException) {
return context.l10n.tcpErrorTimedOut;
}
if (error is StateError) {
return context.l10n.tcpConnectionFailed(error.message);
}
if (error is ArgumentError) {
return context.l10n.tcpConnectionFailed(
error.message?.toString() ?? error.toString(),
);
}
return context.l10n.tcpConnectionFailed(error.toString());
}
}
+91 -69
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);
if (frame[0] == respCodeSent) { try {
_tagData = frame.sublist(2, 6); final cmd = reader.readByte();
if (cmd == respCodeSent) {
reader.skipBytes(1); // Skip the reserved byte
_tagData = reader.readUInt32LE();
_tripTime = reader.readUInt32LE();
_statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: _tripTime), () {
if (!mounted) return;
setState(() {
_isLoading = false;
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
);
_recordTelemetryResult(false);
});
} }
// Check if it's a binary response // Check if it's a binary response
if (frame[0] == pushCodeBinaryResponse && if (cmd == pushCodeBinaryResponse) {
listEquals(frame.sublist(2, 6), _tagData)) {
if (!mounted) return; if (!mounted) return;
_handleStatusResponse(frame.sublist(6)); reader.skipBytes(1); // Skip the reserved byte
if (reader.readUInt32LE() != _tagData) return;
_handleTelemetryResponse(reader.readRemainingBytes());
}
// Check if it's a telemetry response (for chat contacts)
if (cmd == pushCodeTelemetryResponse) {
reader.skipBytes(1); // Skip the reserved byte
final pubkey = reader.readBytes(6);
if (!mounted) return;
if (!listEquals(widget.contact.publicKey.sublist(0, 6), pubkey)) {
return;
}
_handleTelemetryResponse(reader.readRemainingBytes());
}
} catch (e) {
appLogger.error('Error parsing incoming frame: $e');
// If parsing fails, ignore the frame
} }
}); });
} }
void _handleStatusResponse(Uint8List frame) { void _handleTelemetryResponse(Uint8List frame) {
final parsedTelemetry = CayenneLpp.parseByChannel(frame); final parsedTelemetry = CayenneLpp.parseByChannel(frame);
final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry); final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry);
if (batteryMv != null) { if (batteryMv != null) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.updateRepeaterBatterySnapshot( connector.updateRepeaterBatterySnapshot(
widget.repeater.publicKeyHex, widget.contact.publicKeyHex,
batteryMv, batteryMv,
source: 'telemetry', source: 'telemetry',
); );
@@ -105,13 +152,6 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}); });
} }
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
Future<void> _loadTelemetry() async { Future<void> _loadTelemetry() async {
if (_commandService == null) return; if (_commandService == null) return;
@@ -121,41 +161,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}); });
try { try {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector); final selection = await connector.preparePathForContactSend(
final selection = await connector.preparePathForContactSend(repeater); _resolveContact(connector),
);
_pendingStatusSelection = selection; _pendingStatusSelection = selection;
final frame = buildSendBinaryReq( Uint8List frame;
repeater.publicKey, if (widget.contact.type != advTypeChat) {
frame = buildSendBinaryReq(
widget.contact.publicKey,
payload: Uint8List.fromList([reqTypeGetTelemetry]), payload: Uint8List.fromList([reqTypeGetTelemetry]),
); );
await connector.sendFrame(frame); } else {
frame = buildSendTelemetryReq(widget.contact.publicKey);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
var messageBytes = frame.length >= _statusResponseBytes
? frame.length
: _statusResponseBytes;
if (messageBytes < maxFrameSize) {
messageBytes = maxFrameSize;
} }
final timeoutMs = connector.calculateTimeout( await connector.sendFrame(frame);
pathLength: pathLengthValue,
messageBytes: messageBytes,
);
_statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
if (!mounted) return;
setState(() {
_isLoading = false;
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
);
_recordStatusResult(false);
});
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -173,12 +192,16 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
} }
} }
void _recordStatusResult(bool success) { void _recordTelemetryResult(bool success) {
final selection = _pendingStatusSelection; final selection = _pendingStatusSelection;
if (selection == null) return; if (selection == null) return;
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector); connector.recordRepeaterPathResult(
connector.recordRepeaterPathResult(repeater, selection, success, null); widget.contact,
selection,
success,
null,
);
_pendingStatusSelection = null; _pendingStatusSelection = null;
} }
@@ -196,8 +219,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings; final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial; final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
final repeater = _resolveRepeater(connector); final isFloodMode = widget.contact.pathOverride == -1;
final isFloodMode = repeater.pathOverride == -1;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -210,7 +232,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
Text( Text(
repeater.name, widget.contact.name,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
@@ -225,9 +247,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
tooltip: l10n.repeater_routingMode, tooltip: l10n.repeater_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1); await connector.setPathOverride(widget.contact, pathLen: -1);
} else { } else {
await connector.setPathOverride(repeater, pathLen: null); await connector.setPathOverride(widget.contact, pathLen: null);
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
@@ -283,7 +305,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement, tooltip: l10n.repeater_pathManagement,
onPressed: () => onPressed: () =>
PathManagementDialog.show(context, contact: repeater), PathManagementDialog.show(context, contact: widget.contact),
), ),
IconButton( IconButton(
icon: _isLoading icon: _isLoading
@@ -437,7 +459,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final l10n = context.l10n; final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final batteryMv = final batteryMv =
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ?? connector.getRepeaterBatteryMillivolts(widget.contact.publicKeyHex) ??
(telemetryVolts == null ? null : (telemetryVolts * 1000).round()); (telemetryVolts == null ? null : (telemetryVolts * 1000).round());
if (batteryMv == null) return l10n.common_notAvailable; if (batteryMv == null) return l10n.common_notAvailable;
final chemistry = _batteryChemistry(); final chemistry = _batteryChemistry();
@@ -449,7 +471,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
String _batteryChemistry() { String _batteryChemistry() {
final settingsService = context.read<AppSettingsService>(); final settingsService = context.read<AppSettingsService>();
return settingsService.batteryChemistryForRepeater( return settingsService.batteryChemistryForRepeater(
widget.repeater.publicKeyHex, widget.contact.publicKeyHex,
); );
} }
+48 -12
View File
@@ -12,6 +12,7 @@ import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'scanner_screen.dart'; import 'scanner_screen.dart';
import 'tcp_screen.dart';
class UsbScreen extends StatefulWidget { class UsbScreen extends StatefulWidget {
const UsbScreen({super.key}); const UsbScreen({super.key});
@@ -107,16 +108,33 @@ class _UsbScreenState extends State<UsbScreen> {
bottomNavigationBar: Consumer<MeshCoreConnector>( bottomNavigationBar: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final isLoading = _isLoadingPorts; final isLoading = _isLoadingPorts;
final showBle = PlatformInfo.isWeb || final showBle = true;
PlatformInfo.isAndroid || final showTcp = !PlatformInfo.isWeb;
PlatformInfo.isIOS;
return SafeArea( return SafeArea(
top: false, top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (showTcp)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'usb_tcp_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (showTcp && showBle) const SizedBox(width: 12),
if (showBle) if (showBle)
FloatingActionButton.extended( FloatingActionButton.extended(
onPressed: () { onPressed: () {
@@ -127,25 +145,33 @@ class _UsbScreenState extends State<UsbScreen> {
); );
}, },
heroTag: 'usb_ble_action', heroTag: 'usb_ble_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.bluetooth), icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel), label: Text(context.l10n.connectionChoiceBluetoothLabel),
), ),
if (showBle) const SizedBox(width: 12), if ((showTcp || showBle) && !_supportsHotPlug)
const SizedBox(width: 12),
if (!_supportsHotPlug) if (!_supportsHotPlug)
FloatingActionButton.extended( FloatingActionButton.extended(
onPressed: isLoading ? null : _loadPorts, onPressed: isLoading ? null : _loadPorts,
heroTag: 'usb_refresh_action', heroTag: 'usb_refresh_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: isLoading icon: isLoading
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.refresh), : const Icon(Icons.usb),
label: Text(context.l10n.repeater_refresh), label: Text(context.l10n.scanner_scan),
), ),
], ],
), ),
),
); );
}, },
), ),
@@ -191,9 +217,18 @@ class _UsbScreenState extends State<UsbScreen> {
children: [ children: [
Icon(Icons.circle, size: 12, color: statusColor), Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText, statusText,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
), ),
], ],
), ),
@@ -259,8 +294,7 @@ class _UsbScreenState extends State<UsbScreen> {
), ),
subtitle: showRawName ? Text(rawName) : null, subtitle: showRawName ? Text(rawName) : null,
trailing: ElevatedButton( trailing: ElevatedButton(
onPressed: onPressed: isConnecting ? null : () => _connectPort(port),
isConnecting ? null : () => _connectPort(port),
child: Text(l10n.common_connect), child: Text(l10n.common_connect),
), ),
onTap: isConnecting ? null : () => _connectPort(port), onTap: isConnecting ? null : () => _connectPort(port),
@@ -329,8 +363,10 @@ class _UsbScreenState extends State<UsbScreen> {
if (_connector.state != MeshCoreConnectionState.disconnected) return; if (_connector.state != MeshCoreConnectionState.disconnected) return;
final rawPortName = normalizeUsbPortName(port); final rawPortName = normalizeUsbPortName(port);
appLogger.info('Connect tapped for $port (raw: $rawPortName)', appLogger.info(
tag: 'UsbScreen'); 'Connect tapped for $port (raw: $rawPortName)',
tag: 'UsbScreen',
);
try { try {
await _connector.connectUsb(portName: rawPortName); await _connector.connectUsb(portName: rawPortName);
+9 -6
View File
@@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier {
String message, { String message, {
String tag = 'App', String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info, AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) { }) {
if (!_enabled && !kDebugMode) return; if (!_enabled && !kDebugMode) return;
if (!_enabled) { if (!_enabled) {
@@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier {
_entries.removeRange(0, _entries.length - maxEntries); _entries.removeRange(0, _entries.length - maxEntries);
} }
if (!noNotify) {
notifyListeners(); notifyListeners();
}
// Also print to console for development // Also print to console for development
debugPrint('[$tag] $message'); debugPrint('[$tag] $message');
} }
void info(String message, {String tag = 'App'}) { void info(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.info); log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify);
} }
void warn(String message, {String tag = 'App'}) { void warn(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.warning); log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify);
} }
void error(String message, {String tag = 'App'}) { void error(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.error); log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify);
} }
void clear() { void clear() {
+12
View File
@@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value); appLogger.setEnabled(value);
} }
Future<void> setMapShowDiscoveryContacts(bool value) async {
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
}
Future<void> setBatteryChemistryForDevice( Future<void> setBatteryChemistryForDevice(
String deviceId, String deviceId,
String chemistry, String chemistry,
@@ -178,4 +182,12 @@ class AppSettingsService extends ChangeNotifier {
..remove(channelName); ..remove(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated)); await updateSettings(_settings.copyWith(mutedChannels: updated));
} }
Future<void> setTcpServerAddress(String value) async {
await updateSettings(_settings.copyWith(tcpServerAddress: value));
}
Future<void> setTcpServerPort(int value) async {
await updateSettings(_settings.copyWith(tcpServerPort: value));
}
} }
+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();
+167 -43
View File
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
[]; // Rolling buffer of recent ACK hashes []; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact = final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed) {}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, List<String>> _sendQueue =
{}; // contactPubKeyHex ordered list of messageIds awaiting send
final Set<String> _activeMessages =
{}; // messageIds currently in-flight (sent/retrying)
final Set<String> _resolvedMessages =
{}; // messageIds already resolved (prevents double _onMessageResolved)
final Map<String, String> _expectedHashToMessageId = final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash) {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
@@ -52,12 +58,13 @@ class MessageRetryService extends ChangeNotifier {
Function(Message)? _updateMessageCallback; Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback; Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback; Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int)? _calculateTimeoutCallback; Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
Uint8List? Function()? _getSelfPublicKeyCallback; Uint8List? Function()? _getSelfPublicKeyCallback;
String Function(Contact, String)? _prepareContactOutboundTextCallback; String Function(Contact, String)? _prepareContactOutboundTextCallback;
AppSettingsService? _appSettingsService; AppSettingsService? _appSettingsService;
AppDebugLogService? _debugLogService; AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback; Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
Function(String, int, int, int)? _onDeliveryObservedCallback;
MessageRetryService(); MessageRetryService();
@@ -67,12 +74,20 @@ class MessageRetryService extends ChangeNotifier {
required Function(Message) updateMessageCallback, required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback, Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback, Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback, Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback, Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback, String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService, AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService, AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback, Function(String, PathSelection, bool, int?)? recordPathResultCallback,
Function(
String contactKey,
int pathLength,
int messageBytes,
int tripTimeMs,
)?
onDeliveryObservedCallback,
}) { }) {
_sendMessageCallback = sendMessageCallback; _sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback; _addMessageCallback = addMessageCallback;
@@ -85,6 +100,7 @@ class MessageRetryService extends ChangeNotifier {
_appSettingsService = appSettingsService; _appSettingsService = appSettingsService;
_debugLogService = debugLogService; _debugLogService = debugLogService;
_recordPathResultCallback = recordPathResultCallback; _recordPathResultCallback = recordPathResultCallback;
_onDeliveryObservedCallback = onDeliveryObservedCallback;
} }
/// Compute expected ACK hash using same algorithm as firmware: /// Compute expected ACK hash using same algorithm as firmware:
@@ -156,7 +172,49 @@ class MessageRetryService extends ChangeNotifier {
_addMessageCallback!(contact.publicKeyHex, message); _addMessageCallback!(contact.publicKeyHex, message);
} }
await _attemptSend(messageId); // Queue per contact only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId);
if (!_activeMessages.any(
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
)) {
_sendNextForContact(contactKey);
}
}
void _sendNextForContact(String contactKey) {
final queue = _sendQueue[contactKey];
if (queue == null) return;
// Drain stale entries iteratively instead of recursing.
while (queue.isNotEmpty) {
final messageId = queue.removeAt(0);
if (_pendingMessages.containsKey(messageId)) {
_activeMessages.add(messageId);
_attemptSend(messageId).catchError((e) {
debugPrint('_attemptSend threw for $messageId: $e');
final msg = _pendingMessages[messageId];
if (msg != null) {
final failed = msg.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failed;
_updateMessageCallback?.call(failed);
}
_onMessageResolved(messageId, contactKey);
});
return;
}
// Message was cancelled/cleaned up while queued try next
}
}
void _onMessageResolved(String messageId, String contactKey) {
if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId);
_activeMessages.remove(messageId);
_sendNextForContact(contactKey);
} }
Future<void> _attemptSend(String messageId) async { Future<void> _attemptSend(String messageId) async {
@@ -169,13 +227,11 @@ class MessageRetryService extends ChangeNotifier {
// Use the path that was captured when the message was first sent // Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) { if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) { if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path
debugPrint( debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}', 'Setting flood mode for retry attempt ${message.retryCount}',
); );
_clearContactPathCallback!(contact); await _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) { } else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty final pathStr = message.pathBytes.isEmpty
? 'direct' ? 'direct'
: message.pathBytes : message.pathBytes
@@ -192,6 +248,24 @@ class MessageRetryService extends ChangeNotifier {
} }
} }
// Re-validate after async gap a timer or ACK could have resolved/retried
// this message while we were awaiting the path callback.
final currentMessage = _pendingMessages[messageId];
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
debugPrint(
'_attemptSend: message $messageId resolved during path sync, aborting',
);
return;
}
// If the message was retried by a timer during our await, the retryCount
// will have advanced. Only proceed if it still matches the attempt we started.
if (currentMessage.retryCount != message.retryCount) {
debugPrint(
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
);
return;
}
final attempt = message.retryCount.clamp(0, 3); final attempt = message.retryCount.clamp(0, 3);
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
@@ -231,6 +305,15 @@ class MessageRetryService extends ChangeNotifier {
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds); _sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
} else {
// No send callback message would be stuck forever. Fail it immediately.
debugPrint(
'_attemptSend: no sendMessageCallback, failing message $messageId',
);
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
_updateMessageCallback?.call(failedMessage);
_onMessageResolved(messageId, contact.publicKeyHex);
} }
} }
@@ -281,6 +364,7 @@ class MessageRetryService extends ChangeNotifier {
} }
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added) // FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
// Only match within a single contact's queue to avoid cross-contact mismatches.
if (messageId == null && allowQueueFallback) { if (messageId == null && allowQueueFallback) {
_debugLogService?.warn( _debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
@@ -290,13 +374,16 @@ class MessageRetryService extends ChangeNotifier {
'Hash-based match failed for $ackHashHex, falling back to queue-based matching', 'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
); );
for (var entry in _pendingMessageQueuePerContact.entries) { // Search all contact queues so concurrent chats don't miss matches.
final queuesToSearch = _pendingMessageQueuePerContact;
for (var entry in queuesToSearch.entries) {
final contactKey = entry.key; final contactKey = entry.key;
final queue = entry.value; final queue = entry.value;
if (queue.isNotEmpty) { // Drain stale entries until we find a valid one or exhaust the queue.
while (queue.isNotEmpty) {
final candidateMessageId = queue.removeAt(0); final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) { if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId; messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId]; contact = _pendingContacts[candidateMessageId];
@@ -304,21 +391,10 @@ class MessageRetryService extends ChangeNotifier {
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey', 'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
); );
break; break;
} else { }
debugPrint('Dequeued stale message $candidateMessageId - skipping'); debugPrint('Dequeued stale message $candidateMessageId - skipping');
if (queue.isNotEmpty) {
final nextMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break;
}
}
}
} }
if (messageId != null) break;
} }
} }
@@ -357,9 +433,7 @@ class MessageRetryService extends ChangeNotifier {
); );
} }
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid // Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) {
int pathLengthValue; int pathLengthValue;
if (selection != null) { if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount; pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
@@ -369,14 +443,24 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
pathLengthValue = contact.pathLength; pathLengthValue = contact.pathLength;
} }
actualTimeout = _calculateTimeoutCallback!(
int actualTimeout = timeoutMs;
if (_calculateTimeoutCallback != null) {
final calculated = _calculateTimeoutCallback!(
pathLengthValue, pathLengthValue,
message.text.length, message.text.length,
contactKey: contact.publicKeyHex,
); );
// calculateTimeout tries ML first, falls back to physics.
// Use calculated value if device didn't provide one, or if ML
// produced a tighter prediction than the device's estimate.
if (timeoutMs <= 0 || calculated < timeoutMs) {
actualTimeout = calculated;
debugPrint( debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue', 'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
); );
} }
}
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
status: MessageStatus.sent, status: MessageStatus.sent,
@@ -463,22 +547,7 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
// Max retries reached - mark as failed // Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed); final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
// Move ACK hashes to history before removing
_moveAckHashesToHistory(messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
// Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
// Check if we should clear the path on max retry // Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true && if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
@@ -499,6 +568,30 @@ class MessageRetryService extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
// Message is done retrying send next queued message for this contact
_onMessageResolved(messageId, contact.publicKeyHex);
// Keep message in pending maps for 30s grace period so late ACKs
// can still match and update the message to delivered.
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
_moveAckHashesToHistory(messageId);
// Clean up ALL hash mappings for this message
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
final contactKey = contact.publicKeyHex;
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contactKey);
}
});
} }
} }
@@ -594,7 +687,15 @@ class MessageRetryService extends ChangeNotifier {
} }
if (matchedMessageId != null) { if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId]!; final message = _pendingMessages[matchedMessageId];
if (message == null) {
// Message was already cleaned up (e.g. grace period expired)
_ackHashToMessageId.remove(ackHashHex);
debugPrint(
'ACK matched $matchedMessageId but message already cleaned up',
);
return;
}
final contact = _pendingContacts[matchedMessageId]; final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId];
@@ -616,12 +717,21 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs, tripTimeMs: tripTimeMs,
); );
// Clean up ALL hash mappings for this message (from all retry attempts)
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == matchedMessageId,
);
_expectedHashToMessageId.removeWhere(
(_, msgId) => msgId == matchedMessageId,
);
// Move ACK hashes to history before removing // Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId); _moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId); _pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId); _pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId); _pendingPathSelections.remove(matchedMessageId);
_resolvedMessages.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message) // Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) { if (contact != null) {
@@ -646,6 +756,17 @@ class MessageRetryService extends ChangeNotifier {
true, true,
tripTimeMs, tripTimeMs,
); );
if (_onDeliveryObservedCallback != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
_onDeliveryObservedCallback!(
contact.publicKeyHex,
message.pathLength!,
message.text.length,
tripTimeMs,
);
}
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
} }
notifyListeners(); notifyListeners();
@@ -783,6 +904,9 @@ class MessageRetryService extends ChangeNotifier {
_ackHistory.clear(); _ackHistory.clear();
_ackHashToMessageId.clear(); _ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear(); _pendingMessageQueuePerContact.clear();
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
super.dispose(); super.dispose();
} }
} }
+59 -3
View File
@@ -101,8 +101,7 @@ class NotificationService {
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS']; final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
if (addr != null && addr.isNotEmpty) return true; if (addr != null && addr.isNotEmpty) return true;
// Fallback: check the default socket for the current user. // Fallback: check the default socket for the current user.
final uid = Platform.environment['UID'] ?? final uid = Platform.environment['UID'] ?? Platform.environment['EUID'];
Platform.environment['EUID'];
final path = '/run/user/${uid ?? '1000'}/bus'; final path = '/run/user/${uid ?? '1000'}/bus';
return File(path).existsSync(); return File(path).existsSync();
} }
@@ -233,7 +232,9 @@ class NotificationService {
try { try {
await _notifications.show( await _notifications.show(
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, id: contactId != null
? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch,
title: _l10n.notification_newTypeDiscovered(contactType), title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName, body: contactName,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
@@ -332,6 +333,61 @@ class NotificationService {
await _notifications.cancel(id: id); await _notifications.cancel(id: id);
} }
/// Cancel the notification for a specific contact and update the app badge.
Future<void> clearContactNotification(
String contactId,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: contactId.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel the notification for a specific channel and update the app badge.
Future<void> clearChannelNotification(
int channelIndex,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: channelIndex.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel advert notifications for the given contact public key hexes.
Future<void> clearAdvertNotifications(List<String> contactIds) async {
if (!await _ensureInitialized()) return;
for (final id in contactIds) {
await _notifications.cancel(id: 'advert:$id'.hashCode);
}
}
Future<void> _updateBadge(int count) async {
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
// On Apple platforms, set the badge number directly via a silent update.
final darwinDetails = DarwinNotificationDetails(
presentAlert: false,
presentSound: false,
presentBadge: true,
badgeNumber: count,
);
final details = NotificationDetails(
iOS: darwinDetails,
macOS: darwinDetails,
);
// Use a fixed ID so each update replaces the previous one.
await _notifications.show(
id: 'badge_update'.hashCode,
title: null,
body: null,
notificationDetails: details,
);
// Immediately cancel the silent notification so it doesn't appear in tray.
await _notifications.cancel(id: 'badge_update'.hashCode);
}
// On Android, badge count is derived from active notifications,
// so cancelling the specific notification above is sufficient.
}
// //
// Public notification methods (rate limiting is enforced automatically) // Public notification methods (rate limiting is enforced automatically)
// //
+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);
}
} }
+2
View File
@@ -0,0 +1,2 @@
export 'tcp_transport_service_native.dart'
if (dart.library.js_interop) 'tcp_transport_service_web.dart';
@@ -0,0 +1,210 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'app_debug_log_service.dart';
import 'usb_serial_frame_codec.dart';
class TcpTransportService {
final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
StreamSubscription<Uint8List>? _socketSubscription;
Socket? _socket;
AppDebugLogService? _debugLogService;
TcpTransportStatus _status = TcpTransportStatus.disconnected;
String? _activeHost;
int? _activePort;
Future<void> _pendingWrite = Future<void>.value();
int _connectGeneration = 0;
TcpTransportStatus get status => _status;
Stream<Uint8List> get frameStream => _frameController.stream;
bool get isConnected => _status == TcpTransportStatus.connected;
String? get activeEndpoint => _activeHost == null || _activePort == null
? null
: '$_activeHost:$_activePort';
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
Future<void> connect({
required String host,
required int port,
Duration timeout = const Duration(seconds: 10),
}) async {
if (_status == TcpTransportStatus.connected ||
_status == TcpTransportStatus.connecting) {
throw StateError('TCP transport is already active');
}
final trimmedHost = host.trim();
if (trimmedHost.isEmpty) {
throw ArgumentError.value(host, 'host', 'Host cannot be empty');
}
if (port < 1 || port > 65535) {
throw ArgumentError.value(port, 'port', 'Port must be in 1..65535');
}
_status = TcpTransportStatus.connecting;
final generation = ++_connectGeneration;
_frameDecoder.reset();
try {
final socket = await Socket.connect(trimmedHost, port, timeout: timeout);
if (generation != _connectGeneration ||
_status != TcpTransportStatus.connecting) {
try {
await socket.close();
} catch (_) {}
try {
socket.destroy();
} catch (_) {}
return;
}
socket.setOption(SocketOption.tcpNoDelay, true);
_socket = socket;
_activeHost = trimmedHost;
_activePort = port;
_socketSubscription = socket.listen(
_handleSocketData,
onError: _handleSocketError,
onDone: _handleSocketDone,
);
_status = TcpTransportStatus.connected;
_debugLogService?.info(
'TCP transport opened endpoint=$activeEndpoint',
tag: 'TCP',
);
} catch (error) {
await _cleanupFailedConnect();
_status = TcpTransportStatus.disconnected;
rethrow;
}
}
Future<void> write(Uint8List data) async {
if (!isConnected || _socket == null) {
throw StateError('TCP transport is not connected');
}
final packet = wrapUsbSerialTxFrame(data);
_logFrameSummary('TCP TX frame', data);
final writeTask = _pendingWrite.then((_) async {
final socket = _socket;
if (!isConnected || socket == null) {
throw StateError('TCP transport is not connected');
}
socket.add(packet);
await socket.flush();
});
_pendingWrite = writeTask.catchError((_) {});
await writeTask;
}
Future<void> disconnect() async {
_connectGeneration += 1;
if (_status == TcpTransportStatus.disconnected) return;
final endpoint = activeEndpoint;
_status = TcpTransportStatus.disconnecting;
_frameDecoder.reset();
_activeHost = null;
_activePort = null;
final subscription = _socketSubscription;
_socketSubscription = null;
await subscription?.cancel();
final socket = _socket;
_socket = null;
try {
await socket?.close();
} catch (_) {}
try {
socket?.destroy();
} catch (_) {}
_status = TcpTransportStatus.disconnected;
_debugLogService?.info(
'TCP transport closed endpoint=${endpoint ?? 'unknown'}',
tag: 'TCP',
);
}
void dispose() {
unawaited(disconnect().whenComplete(_closeFrameController));
}
Future<void> _cleanupFailedConnect() async {
final subscription = _socketSubscription;
_socketSubscription = null;
await subscription?.cancel();
final socket = _socket;
_socket = null;
try {
await socket?.close();
} catch (_) {}
try {
socket?.destroy();
} catch (_) {}
_activeHost = null;
_activePort = null;
_frameDecoder.reset();
}
void _handleSocketData(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
_debugLogService?.info(
'TCP ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
tag: 'TCP',
);
continue;
}
_addFrame(packet.payload);
}
}
void _handleSocketError(Object error, [StackTrace? stackTrace]) {
_addFrameError(error, stackTrace);
unawaited(disconnect());
}
void _handleSocketDone() {
if (_status == TcpTransportStatus.disconnecting ||
_status == TcpTransportStatus.disconnected) {
return;
}
_addFrameError(StateError('TCP socket closed by remote endpoint'));
unawaited(disconnect());
}
void _addFrame(Uint8List payload) {
if (_frameController.isClosed) return;
_frameController.add(payload);
}
void _addFrameError(Object error, [StackTrace? stackTrace]) {
if (_frameController.isClosed) return;
_frameController.addError(error, stackTrace);
}
void _logFrameSummary(String prefix, Uint8List payload) {
final code = payload.isNotEmpty ? payload.first : -1;
_debugLogService?.info(
'$prefix code=$code len=${payload.length}',
tag: 'TCP',
);
}
Future<void> _closeFrameController() async {
if (_frameController.isClosed) return;
await _frameController.close();
}
}
enum TcpTransportStatus { disconnected, connecting, connected, disconnecting }
@@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'app_debug_log_service.dart';
class TcpTransportService {
AppDebugLogService? _debugLogService;
Stream<Uint8List> get frameStream => const Stream<Uint8List>.empty();
bool get isConnected => false;
String? get activeEndpoint => null;
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
Future<void> connect({
required String host,
required int port,
Duration timeout = const Duration(seconds: 10),
}) async {
_debugLogService?.warn(
'TCP transport requested on web for $host:$port',
tag: 'TCP',
);
throw UnsupportedError('TCP transport is not supported on web.');
}
Future<void> write(Uint8List data) async {
throw UnsupportedError('TCP transport is not supported on web.');
}
Future<void> disconnect() async {}
void dispose() {}
}
@@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
import '../models/delivery_observation.dart';
import 'storage_service.dart';
class _ContactStats {
int count = 0;
double _sum = 0;
void add(double ms) {
count++;
_sum += ms;
}
double get mean => _sum / count;
}
class TimeoutPredictionService extends ChangeNotifier {
final StorageService? _storage;
static const int minObservations = 10;
static const int maxObservations = 100;
static const int _retrainInterval = 5;
// 1.5x multiplier on raw prediction to account for variance in delivery
// times tight enough to improve on worst-case physics, loose enough
// to avoid premature timeouts from model noise.
static const double _safetyMargin = 1.5;
static const int _minContactObservations = 10;
List<DeliveryObservation> _observations = [];
LinearRegressor? _model;
List<String> _activeFeatures = [];
int _observationsSinceLastTrain = 0;
final Map<String, _ContactStats> _contactStats = {};
Timer? _persistTimer;
TimeoutPredictionService(StorageService storage) : _storage = storage;
TimeoutPredictionService.noStorage() : _storage = null;
int get observationCount => _observations.length;
bool get hasModel => _model != null;
Future<void> initialize() async {
_observations = await _storage?.loadDeliveryObservations() ?? [];
_rebuildContactStats();
if (_observations.length >= minObservations) {
_trainModel();
}
debugPrint(
'TimeoutPrediction: initialized with ${_observations.length} observations, '
'model=${_model != null ? "ready" : "waiting for data"}',
);
}
void recordObservation({
required String contactKey,
required int pathLength,
required int messageBytes,
required int tripTimeMs,
int secondsSinceLastRx = 0,
}) {
final observation = DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0,
deliveryMs: tripTimeMs,
timestamp: DateTime.now(),
);
_observations.add(observation);
if (_observations.length > maxObservations) {
_observations.removeAt(0);
}
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) {
_trainModel();
}
_persistTimer?.cancel();
_persistTimer = Timer(const Duration(seconds: 2), () {
_storage?.saveDeliveryObservations(_observations);
});
debugPrint(
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
'(${_observations.length} total)',
);
}
int? predictTimeout({
String? contactKey,
required int pathLength,
required int messageBytes,
int secondsSinceLastRx = 0,
}) {
if (_model == null) return null;
try {
if (_activeFeatures.isEmpty) return null;
final allFeatures = {
'pathLength': pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0,
};
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
final features = DataFrame(
[row],
headerExists: false,
header: _activeFeatures,
);
final prediction = _model!.predict(features);
final rawValue = prediction.rows.first.first;
var predictedMs = (rawValue is double)
? rawValue
: (rawValue as num).toDouble();
debugPrint(
'TimeoutPrediction: raw prediction=$predictedMs for '
'pathLength=$pathLength, messageBytes=$messageBytes, '
'features=$_activeFeatures',
);
// Sanity check: if prediction is negative or zero, fall back
if (predictedMs <= 0) return null;
// Blend with per-contact mean if enough data
if (contactKey != null) {
final stats = _contactStats[contactKey];
if (stats != null && stats.count >= _minContactObservations) {
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
}
}
// Connector clamps this between physics min/max bounds
final timeout = (predictedMs * _safetyMargin).ceil();
debugPrint(
'TimeoutPrediction: ML timeout ${timeout}ms '
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
);
return timeout;
} catch (e) {
debugPrint('TimeoutPrediction: prediction failed: $e');
return null;
}
}
void _trainModel() {
try {
// Build feature columns, then exclude any with zero variance
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
final allExtractors = <double Function(DeliveryObservation)>[
(o) => o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0,
];
_activeFeatures = [];
for (var i = 0; i < allNames.length; i++) {
final values = _observations.map(allExtractors[i]).toSet();
if (values.length > 1) _activeFeatures.add(allNames[i]);
}
if (_activeFeatures.isEmpty) {
debugPrint(
'TimeoutPrediction: no features with variance, skipping training',
);
return;
}
final header = [..._activeFeatures, 'deliveryMs'];
final rows = _observations.map((o) {
final row = <double>[];
for (var i = 0; i < allNames.length; i++) {
if (_activeFeatures.contains(allNames[i])) {
row.add(allExtractors[i](o));
}
}
row.add(o.deliveryMs.toDouble());
return row;
});
final data = DataFrame([header, ...rows], headerExists: true);
_model = LinearRegressor(data, 'deliveryMs');
_observationsSinceLastTrain = 0;
// Log training summary with sample predictions
final avgMs =
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
_observations.length;
debugPrint(
'TimeoutPrediction: trained on ${_observations.length} observations '
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
);
} catch (e) {
debugPrint('TimeoutPrediction: training failed: $e');
}
}
@override
void dispose() {
_persistTimer?.cancel();
super.dispose();
}
void _rebuildContactStats() {
_contactStats.clear();
for (final obs in _observations) {
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
}
}
}
+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),
);
}
}
@@ -189,6 +189,10 @@ class UsbSerialService {
serial.setStopBits1(); serial.setStopBits1();
serial.setFlowControlNone(); serial.setFlowControlNone();
serial.setRTS(false); serial.setRTS(false);
// Toggle DTR lowhigh so the device sees a fresh connection even
// if the previous disconnect didn't cleanly signal DTR drop.
serial.setDTR(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
serial.setDTR(true); serial.setDTR(true);
_serial = serial; _serial = serial;
// Update the normalized port name to whichever candidate succeeded. // Update the normalized port name to whichever candidate succeeded.
@@ -249,6 +253,21 @@ class UsbSerialService {
_status = UsbSerialStatus.connected; _status = UsbSerialStatus.connected;
} }
Future<void> writeRaw(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
}
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
} on PlatformException catch (error) {
throw StateError(error.message ?? error.code);
}
} else {
_serial!.write(data);
}
}
Future<void> write(Uint8List data) async { Future<void> write(Uint8List data) async {
if (!isConnected) { if (!isConnected) {
throw StateError('USB serial port is not open'); throw StateError('USB serial port is not open');
@@ -300,6 +319,7 @@ class UsbSerialService {
_serial = null; _serial = null;
try { try {
if (serial?.isOpen() == FlOpenStatus.open) { if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); serial?.closePort();
} }
} catch (_) { } catch (_) {
@@ -350,6 +370,7 @@ class UsbSerialService {
final serial = _serial; final serial = _serial;
try { try {
if (serial?.isOpen() == FlOpenStatus.open) { if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); // synchronous C call kills the SerialThread serial?.closePort(); // synchronous C call kills the SerialThread
} }
} catch (_) {} } catch (_) {}
+42 -8
View File
@@ -118,10 +118,7 @@ class UsbSerialService {
tag: 'USB Serial', tag: 'USB Serial',
); );
} catch (error) { } catch (error) {
_debugLogService?.error( _debugLogService?.error('Web connect failed: $error', tag: 'USB Serial');
'Web connect failed: $error',
tag: 'USB Serial',
);
await _cleanupFailedConnect(); await _cleanupFailedConnect();
_status = UsbSerialStatus.disconnected; _status = UsbSerialStatus.disconnected;
_connectedPortName = null; _connectedPortName = null;
@@ -130,6 +127,17 @@ class UsbSerialService {
} }
} }
Future<void> writeRaw(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');
}
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
'write'.toJS,
data.toJS,
);
await promise.toDart;
}
Future<void> write(Uint8List data) async { Future<void> write(Uint8List data) async {
if (!isConnected || _writer == null) { if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open'); throw StateError('USB serial port is not open');
@@ -268,9 +276,23 @@ class UsbSerialService {
return null; return null;
} }
Future<void> _openPort(JSObject port, int baudRate) { Future<void> _openPort(JSObject port, int baudRate) async {
final options = JSObject()..['baudRate'] = baudRate.toJS; final options = JSObject()
return port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart; ..['baudRate'] = baudRate.toJS
..['flowControl'] = 'none'.toJS;
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
try {
final signals = JSObject()
..['dataTerminalReady'] = true.toJS
..['requestToSend'] = false.toJS;
await port
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
.toDart;
} catch (_) {
// setSignals may not be supported on all browsers/devices.
}
} }
Future<void> _cleanupFailedConnect() async { Future<void> _cleanupFailedConnect() async {
@@ -324,8 +346,12 @@ class UsbSerialService {
Future<void> _pumpReads() async { Future<void> _pumpReads() async {
final reader = _reader; final reader = _reader;
if (reader == null) return; if (reader == null) {
_debugLogService?.warn('_pumpReads: reader is null', tag: 'USB Serial');
return;
}
_debugLogService?.info('_pumpReads: started', tag: 'USB Serial');
try { try {
while (_status == UsbSerialStatus.connected && while (_status == UsbSerialStatus.connected &&
identical(reader, _reader)) { identical(reader, _reader)) {
@@ -333,6 +359,7 @@ class UsbSerialService {
.callMethod<JSPromise<JSAny?>>('read'.toJS) .callMethod<JSPromise<JSAny?>>('read'.toJS)
.toDart; .toDart;
if (result == null) { if (result == null) {
_debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
break; break;
} }
final resultObject = result as JSObject; final resultObject = result as JSObject;
@@ -340,20 +367,27 @@ class UsbSerialService {
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS); final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
final done = doneValue != null && doneValue.dartify() == true; final done = doneValue != null && doneValue.dartify() == true;
if (done) { if (done) {
_debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
break; break;
} }
final value = resultObject.getProperty<JSAny?>('value'.toJS); final value = resultObject.getProperty<JSAny?>('value'.toJS);
final bytes = _coerceBytes(value); final bytes = _coerceBytes(value);
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
_debugLogService?.info(
'USB RX raw: ${bytes.length} byte(s)',
tag: 'USB Serial',
);
_ingestRawBytes(bytes); _ingestRawBytes(bytes);
} }
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
if (_status == UsbSerialStatus.connected) { if (_status == UsbSerialStatus.connected) {
_addFrameError(error, stackTrace); _addFrameError(error, stackTrace);
} }
} finally { } finally {
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
_releaseLock(reader); _releaseLock(reader);
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) { if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
_addFrameError(StateError('USB serial connection closed')); _addFrameError(StateError('USB serial connection closed'));
+44 -7
View File
@@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
@@ -7,13 +9,25 @@ import 'prefs_manager.dart';
class ChannelMessageStore { class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_'; static const String _keyPrefix = 'channel_messages_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
/// Save messages for a specific channel /// Save messages for a specific channel
Future<void> saveChannelMessages( Future<void> saveChannelMessages(
int channelIndex, int channelIndex,
List<ChannelMessage> messages, List<ChannelMessage> messages,
) async { ) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save channel messages.',
);
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
// Convert messages to JSON // Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
@@ -24,12 +38,35 @@ class ChannelMessageStore {
/// Load messages for a specific channel /// Load messages for a specific channel
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async { Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot load channel messages.',
);
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
final oldKey = '$_keyPrefix$channelIndex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
String? jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey);
prefs.remove(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $oldKey to scoped key $key',
);
await prefs.setString(key, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final jsonList = jsonDecode(jsonString) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList(); return jsonList.map((json) => _messageFromJson(json)).toList();
@@ -42,14 +79,14 @@ class ChannelMessageStore {
/// Clear messages for a specific channel /// Clear messages for a specific channel
Future<void> clearChannelMessages(int channelIndex) async { Future<void> clearChannelMessages(int channelIndex) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
await prefs.remove(key); await prefs.remove(key);
} }
/// Clear all channel messages /// Clear all channel messages
Future<void> clearAllChannelMessages() async { Future<void> clearAllChannelMessages() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix)); final keys = prefs.getKeys().where((k) => k.startsWith(keyFor));
for (var key in keys) { for (var key in keys) {
await prefs.remove(key); await prefs.remove(key);
} }
+35 -6
View File
@@ -1,20 +1,49 @@
import 'dart:convert'; import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelOrderStore { class ChannelOrderStore {
static const String _key = 'channel_order'; static const String _keyPrefix = 'channel_order_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<void> saveChannelOrder(List<int> order) async { Future<void> saveChannelOrder(List<int> order) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save channel order.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.setString(_key, jsonEncode(order)); await prefs.setString(keyFor, jsonEncode(order));
} }
Future<List<int>> loadChannelOrder() async { Future<List<int>> loadChannelOrder() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channel order.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final raw = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (raw == null || raw.isEmpty) return []; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(jsonString);
if (decoded is List) { if (decoded is List) {
return decoded return decoded
.map((value) => value is int ? value : int.tryParse('$value')) .map((value) => value is int ? value : int.tryParse('$value'))
@@ -24,7 +53,7 @@ class ChannelOrderStore {
} catch (_) { } catch (_) {
// fall through to legacy parse // fall through to legacy parse
} }
return raw return jsonString
.split(',') .split(',')
.map((value) => int.tryParse(value)) .map((value) => int.tryParse(value))
.whereType<int>() .whereType<int>()
+36 -4
View File
@@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelSettingsStore { class ChannelSettingsStore {
static const String _smazKeyPrefix = 'channel_smaz_'; static const String _keyPrefix = 'channel_smaz_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<bool> loadSmazEnabled(int channelIndex) async { Future<bool> loadSmazEnabled(int channelIndex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot load channel settings.',
);
return false;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
return prefs.getBool(key) ?? false; final oldKey = '$_keyPrefix$channelIndex';
bool? enabled = prefs.getBool(oldKey);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating channel settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
}
}
return enabled ?? false;
} }
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async { Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save channel settings.',
);
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
} }
} }
+37 -5
View File
@@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/channel.dart'; import '../models/channel.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelStore { class ChannelStore {
static const String _key = 'channels'; static const String _keyPrefix = 'channels';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<Channel>> loadChannels() async { Future<List<Channel>> loadChannels() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channels.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return []; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
@@ -23,9 +51,13 @@ class ChannelStore {
} }
Future<void> saveChannels(List<Channel> channels) async { Future<void> saveChannels(List<Channel> channels) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save channels.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = channels.map(_toJson).toList(); final jsonList = channels.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(Channel channel) { Map<String, dynamic> _toJson(Channel channel) {
+33 -3
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../models/community.dart'; import '../models/community.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
/// Persists communities to local storage using SharedPreferences. /// Persists communities to local storage using SharedPreferences.
@@ -9,12 +10,37 @@ import 'prefs_manager.dart';
/// Each community contains its secret K, so this data should /// Each community contains its secret K, so this data should
/// be considered sensitive (though device encryption handles security). /// be considered sensitive (though device encryption handles security).
class CommunityStore { class CommunityStore {
static const String _communitiesKey = 'communities_v1'; static const String _keyPrefix = 'communities_v1';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
/// Load all communities from storage /// Load all communities from storage
Future<List<Community>> loadCommunities() async { Future<List<Community>> loadCommunities() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load communities.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonString = prefs.getString(_communitiesKey); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return []; return [];
} }
@@ -32,9 +58,13 @@ class CommunityStore {
/// Save all communities to storage /// Save all communities to storage
Future<void> saveCommunities(List<Community> communities) async { Future<void> saveCommunities(List<Community> communities) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save communities.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = communities.map((c) => c.toJson()).toList(); final jsonList = communities.map((c) => c.toJson()).toList();
await prefs.setString(_communitiesKey, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
/// Add a new community /// Add a new community
+33 -11
View File
@@ -1,15 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/discovery_contact.dart'; import '../models/contact.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactDiscoveryStore { class ContactDiscoveryStore {
static const String _key = 'discovered_contacts'; static const String _keyPrefix = 'discovered_contacts';
Future<List<DiscoveryContact>> loadContacts() async { Future<List<Contact>> loadContacts() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); final jsonStr = prefs.getString(_keyPrefix);
if (jsonStr == null) return []; if (jsonStr == null) return [];
try { try {
@@ -22,40 +22,62 @@ class ContactDiscoveryStore {
} }
} }
Future<void> saveContacts(List<DiscoveryContact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(_keyPrefix, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(DiscoveryContact contact) { Map<String, dynamic> _toJson(Contact contact) {
return { return {
'rawPacket': base64Encode(contact.rawPacket),
'publicKey': base64Encode(contact.publicKey), 'publicKey': base64Encode(contact.publicKey),
'name': contact.name, 'name': contact.name,
'type': contact.type, 'type': contact.type,
'flags': contact.flags,
'pathLength': contact.pathLength, 'pathLength': contact.pathLength,
'path': base64Encode(contact.path), 'path': base64Encode(contact.path),
'pathOverride': contact.pathOverride,
'pathOverrideBytes': contact.pathOverrideBytes != null
? base64Encode(contact.pathOverrideBytes!)
: null,
'latitude': contact.latitude, 'latitude': contact.latitude,
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
'rawPacket': contact.rawPacket != null
? base64Encode(contact.rawPacket!)
: null,
}; };
} }
DiscoveryContact _fromJson(Map<String, dynamic> json) { Contact _fromJson(Map<String, dynamic> json) {
final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastSeenMs = json['lastSeen'] as int? ?? 0;
return DiscoveryContact( final lastMessageMs = json['lastMessageAt'] as int?;
rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)), return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown', name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0, type: json['type'] as int? ?? 0,
flags: json['flags'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1, pathLength: json['pathLength'] as int? ?? -1,
path: json['path'] != null path: json['path'] != null
? Uint8List.fromList(base64Decode(json['path'] as String)) ? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0), : Uint8List(0),
pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList(
base64Decode(json['pathOverrideBytes'] as String),
)
: null,
latitude: (json['latitude'] as num?)?.toDouble(), latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
isActive: false,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
); );
} }
} }
+37 -5
View File
@@ -1,17 +1,45 @@
import 'dart:convert'; import 'dart:convert';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactGroupStore { class ContactGroupStore {
static const String _key = 'contact_groups'; static const String _keyPrefix = 'contact_groups';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<ContactGroup>> loadGroups() async { Future<List<ContactGroup>> loadGroups() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load contact groups.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final raw = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (raw == null || raw.isEmpty) return []; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(jsonString);
if (decoded is List) { if (decoded is List) {
return decoded return decoded
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
@@ -25,8 +53,12 @@ class ContactGroupStore {
} }
Future<void> saveGroups(List<ContactGroup> groups) async { Future<void> saveGroups(List<ContactGroup> groups) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save contact groups.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList()); final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
await prefs.setString(_key, encoded); await prefs.setString(keyFor, encoded);
} }
} }
+35 -3
View File
@@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactSettingsStore { class ContactSettingsStore {
static const String _smazKeyPrefix = 'contact_smaz_'; static const String _keyPrefix = 'contact_smaz_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<bool> loadSmazEnabled(String contactKeyHex) async { Future<bool> loadSmazEnabled(String contactKeyHex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot load contact settings.',
);
return false;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final oldKey = '$_keyPrefix$contactKeyHex';
bool? enabled = prefs.getBool(key);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating contact settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
}
}
return prefs.getBool(key) ?? false; return prefs.getBool(key) ?? false;
} }
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async { Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save contact settings.',
);
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
} }
} }
+45 -5
View File
@@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/contact.dart'; import '../models/contact.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactStore { class ContactStore {
static const String _key = 'contacts'; static const String _keyPrefix = 'contacts';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<Contact>> loadContacts() async { Future<List<Contact>> loadContacts() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load contacts.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return []; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
@@ -23,9 +51,13 @@ class ContactStore {
} }
Future<void> saveContacts(List<Contact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save contacts.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(Contact contact) { Map<String, dynamic> _toJson(Contact contact) {
@@ -44,6 +76,10 @@ class ContactStore {
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch, 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
'isActive': contact.isActive,
'rawPacket': contact.rawPacket != null
? base64Encode(contact.rawPacket!)
: null,
}; };
} }
@@ -71,6 +107,10 @@ class ContactStore {
lastMessageAt: DateTime.fromMillisecondsSinceEpoch( lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs, lastMessageMs ?? lastSeenMs,
), ),
isActive: json['isActive'] as bool? ?? true,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
); );
} }
} }
+42 -5
View File
@@ -2,26 +2,59 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/message.dart'; import '../models/message.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class MessageStore { class MessageStore {
static const String _keyPrefix = 'messages_'; static const String _keyPrefix = 'messages_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<void> saveMessages( Future<void> saveMessages(
String contactKeyHex, String contactKeyHex,
List<Message> messages, List<Message> messages,
) async { ) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save messages.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList(); final jsonList = messages.map(_messageToJson).toList();
await prefs.setString(key, jsonEncode(jsonList)); await prefs.setString(key, jsonEncode(jsonList));
} }
Future<List<Message>> loadMessages(String contactKeyHex) async { Future<List<Message>> loadMessages(String contactKeyHex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load messages.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final jsonString = prefs.getString(key); final oldKey = '$_keyPrefix$contactKeyHex';
if (jsonString == null) return []; String? jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey);
prefs.remove(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating messages from legacy key $oldKey to scoped key $key',
);
await prefs.setString(key, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final jsonList = jsonDecode(jsonString) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
@@ -32,8 +65,12 @@ class MessageStore {
} }
Future<void> clearMessages(String contactKeyHex) async { Future<void> clearMessages(String contactKeyHex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot clear messages.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
await prefs.remove(key); await prefs.remove(key);
} }
+37 -5
View File
@@ -1,11 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
/// Storage for unread message tracking with debounced writes to reduce I/O. /// Storage for unread message tracking with debounced writes to reduce I/O.
class UnreadStore { class UnreadStore {
static const String _contactUnreadCountKey = 'contact_unread_count'; static const String _keyPrefix = 'contact_unread_count';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
// Debounce timers to batch rapid writes // Debounce timers to batch rapid writes
Timer? _contactUnreadSaveTimer; Timer? _contactUnreadSaveTimer;
@@ -20,12 +27,33 @@ class UnreadStore {
} }
Future<Map<String, int>> loadContactUnreadCount() async { Future<Map<String, int>> loadContactUnreadCount() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load unread counts.');
return {};
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_contactUnreadCountKey); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return {}; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return {};
}
try { try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>; final json = jsonDecode(jsonString) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int)); return json.map((key, value) => MapEntry(key, value as int));
} catch (_) { } catch (_) {
return {}; return {};
@@ -33,6 +61,10 @@ class UnreadStore {
} }
void saveContactUnreadCount(Map<String, int> counts) { void saveContactUnreadCount(Map<String, int> counts) {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save unread counts.');
return;
}
_pendingContactUnreadCount = counts; _pendingContactUnreadCount = counts;
_contactUnreadSaveTimer?.cancel(); _contactUnreadSaveTimer?.cancel();
@@ -49,7 +81,7 @@ class UnreadStore {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(_pendingContactUnreadCount); final jsonStr = jsonEncode(_pendingContactUnreadCount);
await prefs.setString(_contactUnreadCountKey, jsonStr); await prefs.setString(keyFor, jsonStr);
_pendingContactUnreadCount = null; _pendingContactUnreadCount = null;
} }
+8 -7
View File
@@ -23,23 +23,23 @@ class AppLogger {
bool get isEnabled => _enabled; bool get isEnabled => _enabled;
/// Log an info message /// Log an info message
void info(String message, {String tag = 'App'}) { void info(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.info(message, tag: tag); _service!.info(message, tag: tag, noNotify: noNotify);
} }
} }
/// Log a warning message /// Log a warning message
void warn(String message, {String tag = 'App'}) { void warn(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.warn(message, tag: tag); _service!.warn(message, tag: tag, noNotify: noNotify);
} }
} }
/// Log an error message /// Log an error message
void error(String message, {String tag = 'App'}) { void error(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.error(message, tag: tag); _service!.error(message, tag: tag, noNotify: noNotify);
} }
} }
@@ -48,9 +48,10 @@ class AppLogger {
String message, { String message, {
String tag = 'App', String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info, AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) { }) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level); _service!.log(message, tag: tag, level: level, noNotify: noNotify);
} }
} }
} }
+3
View File
@@ -0,0 +1,3 @@
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
+3 -3
View File
@@ -1,7 +1,7 @@
import 'package:meshcore_open/models/discovery_contact.dart';
import '../models/contact.dart'; import '../models/contact.dart';
export 'contact_filter_types.dart';
bool matchesContactQuery(Contact contact, String query) { bool matchesContactQuery(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return true; if (normalizedQuery.isEmpty) return true;
@@ -16,7 +16,7 @@ bool matchesContactQuery(Contact contact, String query) {
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix); return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
} }
bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) { bool matchesDiscoveryContactQuery(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return true; if (normalizedQuery.isEmpty) return true;
+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;
} }
}, },
); );
+15 -4
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) {
@@ -78,7 +89,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes), path: Uint8List.fromList(pathBytes),
flipPathRound: true, flipPathAround: true,
targetContact: widget.contact, targetContact: widget.contact,
), ),
), ),
@@ -107,7 +118,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
} }
final pathForInput = currentContact.pathIdList; final pathForInput = currentContact.pathIdList;
final availableContacts = connector.contacts final availableContacts = connector.allContacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex) .where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList(); .toList();
+2 -1
View File
@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
void _filterValidContacts() { void _filterValidContacts() {
_validContacts = widget.availableContacts _validContacts = widget.availableContacts
.where((c) => c.type == 2 || c.type == 3) .where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.toList(); .toList();
} }
+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 {
+2 -2
View File
@@ -157,8 +157,8 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr, repeater.snr,
widget.connector.currentSf, widget.connector.currentSf,
); );
final allContacts = widget.connector.allContacts;
final name = widget.connector.contacts final name = allContacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte) .where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name) .map((c) => c.name)
.firstOrNull; .firstOrNull;
+3 -1
View File
@@ -14,9 +14,11 @@
<true/> <true/>
<key>com.apple.security.device.usb</key> <key>com.apple.security.device.usb</key>
<true/> <true/>
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key> <key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array> <array>
<string>/dev/</string> <string>/dev/cu.</string>
<string>/dev/tty.</string>
</array> </array>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
+3 -1
View File
@@ -10,9 +10,11 @@
<true/> <true/>
<key>com.apple.security.device.usb</key> <key>com.apple.security.device.usb</key>
<true/> <true/>
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key> <key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array> <array>
<string>/dev/</string> <string>/dev/cu.</string>
<string>/dev/tty.</string>
</array> </array>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
+4 -1
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
@@ -38,6 +38,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_blue_plus: ^2.1.0 flutter_blue_plus: ^2.1.0
# TODO: Switch to official flserial repo once changes are upstreamed
flserial: flserial:
git: git:
url: https://github.com/MeshEnvy/flserial.git url: https://github.com/MeshEnvy/flserial.git
@@ -68,6 +69,8 @@ dependencies:
material_symbols_icons: ^4.2906.0 material_symbols_icons: ^4.2906.0
web: ^1.1.1 web: ^1.1.1
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
ml_algo: ^16.0.0
ml_dataframe: ^1.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
void main() {
group('shouldIgnoreLateTcpConnectError', () {
test('returns true for manual cancel during disconnecting state', () {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: true,
state: MeshCoreConnectionState.disconnecting,
activeTransport: MeshCoreTransportType.bluetooth,
tcpManagerConnected: false,
);
expect(result, isTrue);
});
test(
'returns true for manual cancel after reaching disconnected state',
() {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: true,
state: MeshCoreConnectionState.disconnected,
activeTransport: MeshCoreTransportType.bluetooth,
tcpManagerConnected: false,
);
expect(result, isTrue);
},
);
test('returns false when not a manual disconnect', () {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: false,
state: MeshCoreConnectionState.disconnecting,
activeTransport: MeshCoreTransportType.bluetooth,
tcpManagerConnected: false,
);
expect(result, isFalse);
});
test('returns false for connected state handshake failures', () {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: true,
state: MeshCoreConnectionState.connected,
activeTransport: MeshCoreTransportType.tcp,
tcpManagerConnected: true,
);
expect(result, isFalse);
});
test('returns false when TCP is still active while disconnecting', () {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: true,
state: MeshCoreConnectionState.disconnecting,
activeTransport: MeshCoreTransportType.tcp,
tcpManagerConnected: true,
);
expect(result, isFalse);
});
});
group('shouldResetStateAfterTcpConnectAbort', () {
test('returns true when TCP connect is still in connecting state', () {
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
state: MeshCoreConnectionState.connecting,
activeTransport: MeshCoreTransportType.tcp,
);
expect(result, isTrue);
});
test('returns false when state is already disconnected', () {
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
state: MeshCoreConnectionState.disconnected,
activeTransport: MeshCoreTransportType.tcp,
);
expect(result, isFalse);
});
test('returns false when transport switched away from TCP', () {
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
state: MeshCoreConnectionState.connecting,
activeTransport: MeshCoreTransportType.bluetooth,
);
expect(result, isFalse);
});
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/screens/scanner_screen.dart';
import 'package:meshcore_open/screens/tcp_screen.dart';
import 'package:meshcore_open/services/app_settings_service.dart';
class _FakeMeshCoreConnector extends MeshCoreConnector {
_FakeMeshCoreConnector();
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
String? initialEndpoint;
int connectTcpCalls = 0;
String? lastHost;
int? lastPort;
@override
MeshCoreConnectionState get state => initialState;
@override
MeshCoreTransportType get activeTransport => initialTransport;
@override
bool get isTcpTransportConnected =>
initialState == MeshCoreConnectionState.connected &&
initialTransport == MeshCoreTransportType.tcp;
@override
String? get activeTcpEndpoint => initialEndpoint;
@override
Future<void> connectTcp({required String host, required int port}) async {
connectTcpCalls += 1;
lastHost = host;
lastPort = port;
}
}
Widget _buildTestApp({
required MeshCoreConnector connector,
required Widget child,
Locale? locale,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<MeshCoreConnector>.value(value: connector),
ChangeNotifierProvider<AppSettingsService>(
create: (_) => AppSettingsService(),
),
],
child: MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
),
);
}
void main() {
testWidgets('TcpScreen uses localized TCP copy', (tester) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
expect(find.text(l10n.tcpScreenTitle), findsOneWidget);
expect(find.text(l10n.tcpHostLabel), findsOneWidget);
expect(find.text(l10n.tcpPortLabel), findsOneWidget);
expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget);
});
testWidgets('TcpScreen validation errors are localized', (tester) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
await tester.enterText(find.byType(TextField).first, '');
await tester.tap(find.byKey(const Key('tcp_connect_button')));
await tester.pumpAndSettle();
expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget);
expect(connector.connectTcpCalls, 0);
await tester.enterText(find.byType(TextField).first, '192.168.1.50');
await tester.enterText(find.byType(TextField).at(1), '99999');
await tester.tap(find.byKey(const Key('tcp_connect_button')));
await tester.pumpAndSettle();
expect(connector.connectTcpCalls, 0);
});
testWidgets('TCP Bluetooth action returns to existing scanner route', (
tester,
) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const ScannerScreen()),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP'));
await tester.pumpAndSettle();
expect(find.byType(TcpScreen), findsOneWidget);
await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth'));
await tester.pumpAndSettle();
expect(find.byType(TcpScreen), findsNothing);
expect(find.byType(ScannerScreen), findsOneWidget);
final navigatorState = tester.state<NavigatorState>(find.byType(Navigator));
expect(navigatorState.canPop(), isFalse);
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
// Drain that debounce timer before test teardown.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
testWidgets('TcpScreen disables connect button while connector is scanning', (
tester,
) async {
final connector = _FakeMeshCoreConnector()
..initialState = MeshCoreConnectionState.scanning;
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final button = tester.widget<ButtonStyleButton>(
find.byKey(const Key('tcp_connect_button')),
);
expect(button.onPressed, isNull);
expect(connector.connectTcpCalls, 0);
});
testWidgets('TcpScreen narrow width long status text does not overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final connector = _FakeMeshCoreConnector()
..initialState = MeshCoreConnectionState.connected
..initialTransport = MeshCoreTransportType.tcp
..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000';
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
expect(
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
}

Some files were not shown because too many files have changed in this diff Show More