mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-15 23:24:29 +10:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0228c38621 | |||
| fc7283f076 | |||
| 58252b8a40 | |||
| 630606acdc | |||
| 767dc1164e | |||
| dbefb0b5f4 | |||
| 1392c2d00f | |||
| cb63b48b78 | |||
| 4ad4a93a20 | |||
| 4962a48e64 | |||
| 53caec3e14 | |||
| 3c440ca3d4 | |||
| 8797d8ffde | |||
| faba120823 | |||
| be690c8194 | |||
| 64d75dde45 | |||
| 9199aab7f7 | |||
| 60e8ee0130 | |||
| 6dfb7a4b69 | |||
| 28a423e0a8 | |||
| 3593cfa843 | |||
| dc85e7a41c | |||
| 9265daaf16 | |||
| 4b744184c2 | |||
| 64698e0be6 | |||
| 3dd9037be3 | |||
| 06a906f4f7 | |||
| 054a84031e | |||
| fffcff3b74 | |||
| b336aedbc5 | |||
| 2ee2358ecc | |||
| 24fa78741b | |||
| 79a45c527b | |||
| 8b280b37be | |||
| fa4da979af | |||
| 91608ff09e | |||
| 71f59d23df |
@@ -0,0 +1 @@
|
|||||||
|
6.2.4
|
||||||
@@ -51,7 +51,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
|||||||
|
|
||||||
### Device Management
|
### Device Management
|
||||||
|
|
||||||
- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth
|
- **BLE, USB, TCP Connection**: Scan and connect to MeshCore devices via Bluetooth, USB or TCP
|
||||||
- **Device Settings**: Configure radio parameters, power settings, and network options
|
- **Device Settings**: Configure radio parameters, power settings, and network options
|
||||||
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
|
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
|
||||||
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
|
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
|
||||||
@@ -75,10 +75,16 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
|||||||
|
|
||||||
### Platform Support
|
### Platform Support
|
||||||
|
|
||||||
- ✅ **Android**: Full support (API 21+)
|
| Feature | Android (API 21+) | iOS (12+) | Linux | Windows | macOS | Web |
|
||||||
- ✅ **iOS**: Full support (iOS 12+)
|
|--------------------|:-----------------:|:---------:|:-----:|:-------:|:-----:|:---------------------------------:|
|
||||||
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
|
| BLE companion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
- 🚧 **Web**: Under construction (Chrome)
|
| USB companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| TCP companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ❌<br>(requires websocket bridge) |
|
||||||
|
| Core Functionality | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Mesh Network | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Map & Location | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Device Management | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Repeater Hub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
@@ -189,6 +195,7 @@ Messages are transmitted as binary frames using a custom protocol optimized for
|
|||||||
### App Settings
|
### App Settings
|
||||||
|
|
||||||
- **Theme**: System default, light, or dark mode
|
- **Theme**: System default, light, or dark mode
|
||||||
|
- **Language**: Use one of 15 languages (English, Chinese, French, Spanish, Portuguese, German, Dutch, Polish, Swedish, Italian, Slovak, Slovene, Bulgarian, Russian, Ukrainian)
|
||||||
- **Notifications**: Configurable for messages, channels, and node advertisements
|
- **Notifications**: Configurable for messages, channels, and node advertisements
|
||||||
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
|
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
|
||||||
- **Message Retry**: Automatic retry with configurable path clearing
|
- **Message Retry**: Automatic retry with configurable path clearing
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
android {
|
android {
|
||||||
namespace = "com.meshcore.meshcore_open"
|
namespace = "com.meshcore.meshcore_open"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = "29.0.14206865"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# MeshCore Open - Feature Documentation
|
||||||
|
|
||||||
|
MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking devices. This documentation covers every user-facing feature, how to access it, and what it does.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Scanner & Connection](scanner-and-connection.md) - BLE scanning, USB serial, and TCP connection
|
||||||
|
2. [Navigation](navigation.md) - App flow, device screen, and quick-switch navigation
|
||||||
|
3. [Contacts](contacts.md) - Contact management, groups, discovery, and sharing
|
||||||
|
4. [Chat & Messaging](chat-and-messaging.md) - Direct messages, message status, reactions, and retries
|
||||||
|
5. [Channels](channels.md) - Broadcast channels, communities, and channel chat
|
||||||
|
6. [Map & Location](map-and-location.md) - Node map, path tracing, line-of-sight, and offline caching
|
||||||
|
7. [Settings](settings.md) - Device settings, app settings, radio configuration, and exports
|
||||||
|
8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences
|
||||||
|
9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors
|
||||||
|
10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more
|
||||||
|
11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture
|
||||||
|
|
||||||
|
## App Overview
|
||||||
|
|
||||||
|
MeshCore Open connects to MeshCore LoRa mesh radios over BLE, USB, or TCP. Once connected, users can:
|
||||||
|
|
||||||
|
- **Chat** with other mesh nodes via encrypted direct messages
|
||||||
|
- **Broadcast** on shared channels (public, hashtag, private, or community-scoped)
|
||||||
|
- **View nodes on a map** with GPS locations, predicted positions, and path traces
|
||||||
|
- **Manage repeaters** with CLI access, telemetry, neighbor info, and settings
|
||||||
|
- **Share contacts** via `meshcore://` URIs and QR codes
|
||||||
|
- **Configure radio settings** including frequency, power, bandwidth, and spreading factor
|
||||||
|
- **Cache offline maps** for use without internet connectivity
|
||||||
|
- **Analyze line-of-sight** between nodes with terrain elevation profiles
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Additional Features
|
||||||
|
|
||||||
|
## GIF Picker (Giphy Integration)
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
In any chat screen (direct or channel), tap the GIF button in the message input bar.
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
A bottom sheet with a search field and a grid of GIF thumbnails.
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
- On open, loads trending GIFs (G-rated, 25 results)
|
||||||
|
- Type to search and press the keyboard submit button (search triggers on submit, not on each keystroke). Clearing the search field reloads trending GIFs
|
||||||
|
- On network/API errors, a "Retry" button is shown in-place
|
||||||
|
- Tap a GIF to select it — the chat input shows an inline preview with an X button to dismiss
|
||||||
|
- Send the message to transmit the GIF reference (`g:<giphy-id>`)
|
||||||
|
- Recipients see the GIF rendered inline via Giphy CDN
|
||||||
|
- "Powered by Giphy" attribution is always shown at the bottom of the picker
|
||||||
|
- The bottom sheet occupies 70% of screen height
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Localization / Multi-Language Support
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
App Settings → Appearance → Language
|
||||||
|
|
||||||
|
### Supported Languages (15)
|
||||||
|
English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
- All UI strings go through Flutter's ARB localization system
|
||||||
|
- Language can follow the system locale or be explicitly overridden
|
||||||
|
- Changes take effect immediately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discovered Contacts Screen
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
From Contacts screen → overflow menu → "Discovered Contacts"
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
A list of nodes heard passively over the air but not yet added as contacts. Each shows:
|
||||||
|
- Color-coded avatar (by type)
|
||||||
|
- Name
|
||||||
|
- Short public key
|
||||||
|
- Last-seen time
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
- Search bar with debounced filtering
|
||||||
|
- Sort by last seen or name; filter by type
|
||||||
|
- **Tap**: Import the contact (adds to your contact list)
|
||||||
|
- **Long-press**: Add Contact, Copy `meshcore://` URI to clipboard, or Delete
|
||||||
|
- Overflow menu → "Delete All" (with confirmation)
|
||||||
|
- Already-known contacts and your own node are filtered out
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SMAZ Compression
|
||||||
|
|
||||||
|
### What It Is
|
||||||
|
An optional per-contact and per-channel text compression feature using the SMAZ algorithm (optimized for short English text).
|
||||||
|
|
||||||
|
### How to Enable
|
||||||
|
- **Per contact**: Chat screen → info button → toggle "SMAZ compression"
|
||||||
|
- **Per channel**: Long-press channel → Edit → toggle "SMAZ compression"
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
- When enabled, compression is applied using a "compress only if smaller" strategy — the message is only transmitted compressed if the encoded result is actually shorter than the original. Otherwise, the original text is sent uncompressed
|
||||||
|
- Compressed messages are transmitted with a `s:` prefix followed by base64-encoded data
|
||||||
|
- Recipients using MeshCore Open will decompress automatically. **Recipients using other software** that is not SMAZ-aware will see garbled `s:...` text
|
||||||
|
- The codec operates on ASCII. Non-ASCII / non-English text generally does not benefit from compression and may even expand. Best suited for short English messages
|
||||||
|
- Disabled by default
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Community QR Scanner
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
From Channels screen → "+" FAB → "Scan Community QR"
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
A live QR scanner view with instruction text overlay.
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
- Scan a community QR code shared by another member
|
||||||
|
- On valid scan: confirmation dialog showing community name and ID
|
||||||
|
- Option to "Add public channel to device" on join
|
||||||
|
- If already a member: shows an "Already a member" dialog
|
||||||
|
- Invalid QR: shows an orange error snackbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channel Message Path Viewing
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
In a channel chat, tap a message bubble (mobile) or use the "Path" action (desktop).
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
- Summary card: sender, time, repeat count, path type, observed hops
|
||||||
|
- "Other Observed Paths" section (if multiple paths detected)
|
||||||
|
- "Repeater Hops" section listing each hop with hex prefix, resolved name, and GPS coordinates
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
- **Radar icon**: Opens path trace map for live trace
|
||||||
|
- **Map icon**: Opens a map with hop markers and polyline
|
||||||
|
- **Path dropdown**: Switch between observed path variants (if multiple)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug Logging
|
||||||
|
|
||||||
|
### BLE Debug Log
|
||||||
|
**Access**: Settings → BLE Debug Log
|
||||||
|
|
||||||
|
Two views:
|
||||||
|
- **Frames**: Each BLE frame with direction, description, hex preview, timestamp. Long-press to copy hex.
|
||||||
|
- **Raw Log RX**: Decoded LoRa packets with route type, payload type, path bytes, and summary.
|
||||||
|
|
||||||
|
### App Debug Log
|
||||||
|
**Access**: Settings → App Debug Log (must be enabled first in App Settings → Debug)
|
||||||
|
|
||||||
|
Structured log entries with level (Info/Warning/Error), tag, message, and timestamp.
|
||||||
|
|
||||||
|
Both logs support copy-all and clear operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chrome Required Screen
|
||||||
|
|
||||||
|
### When It Appears
|
||||||
|
Automatically shown on web platforms when a non-Chromium browser is detected.
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
A full-screen informational page explaining that Web Bluetooth requires a Chromium-based browser. No interactive elements — purely informational.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path History Service
|
||||||
|
|
||||||
|
### What It Does (Background Service)
|
||||||
|
Maintains an in-memory LRU cache of up to 50 contacts, each with up to 100 route history entries, tracking:
|
||||||
|
- Hop count and trip time
|
||||||
|
- Success/failure counts and route weights
|
||||||
|
- Flood vs. direct discovery
|
||||||
|
|
||||||
|
### Path Scoring
|
||||||
|
Paths are scored using a weighted formula: reliability (45%), route weight (20%), latency (25%), and freshness (10%). These weights are internal and not user-configurable. Paths whose weight drops to zero or below are automatically deleted. Flood deliveries that receive an ACK give a weight boost (+0.5) to the specific return path.
|
||||||
|
|
||||||
|
Used internally for:
|
||||||
|
- **Auto route rotation**: Cycles through known paths using configurable weights on retries, with a diversity window to avoid re-using recently tried paths
|
||||||
|
- **Path selection**: Picks the best-scored path for each retry attempt
|
||||||
|
- **Flood statistics**: Tracks flood vs. direct discovery ratios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Message Retry Service
|
||||||
|
|
||||||
|
### What It Does (Background Service)
|
||||||
|
Handles reliable delivery of outgoing direct messages:
|
||||||
|
1. Assigns a UUID and sends immediately. Only one message per contact can be in-flight at a time (avoids overflowing the firmware's 8-entry ACK table); subsequent messages are queued
|
||||||
|
2. Listens for ACK frames matched via SHA-256 hash of `[timestamp][attempt][text][sender_pubkey]`
|
||||||
|
3. On timeout, retries with exponential backoff: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s...)
|
||||||
|
4. Each retry may use a different path (via path history diversity window)
|
||||||
|
5. After max retries: marks failed but keeps a **30-second grace window** during which a late ACK can still resolve the message to "delivered". Optionally clears the contact's path
|
||||||
|
6. Reports RTT and path data for quality learning
|
||||||
|
7. Maintains an ACK hash history (last 50 entries) to handle duplicate ACKs
|
||||||
|
|
||||||
|
### Configurable Settings (App Settings → Messaging)
|
||||||
|
- Max retries (2–10, default 5)
|
||||||
|
- Clear path on max retry (on/off)
|
||||||
|
- Auto route rotation with weight parameters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeout Prediction (ML)
|
||||||
|
|
||||||
|
### What It Does (Background Service)
|
||||||
|
An ML-based service that predicts expected delivery timeouts:
|
||||||
|
- Collects delivery observations (path length, message size, time since last RX, delivery time) in a sliding window of up to 100 observations (oldest evicted first)
|
||||||
|
- Requires **10 minimum observations** before first training. After that, retrains every 5 new observations
|
||||||
|
- Applies a **1.5x safety margin** to raw predictions (the actual timeout issued is 1.5× the model's predicted delivery time)
|
||||||
|
- Features with zero variance are automatically excluded from training
|
||||||
|
- Blends per-contact statistics with ML predictions
|
||||||
|
- Falls back to `3000 + 3000 × pathLength` ms when insufficient data
|
||||||
|
- Observations are persisted to storage via a 2-second debounced timer (observations within 2s of app termination may be lost)
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# BLE Protocol & Data Layer
|
||||||
|
|
||||||
|
This is a technical reference for the communication protocol and data architecture.
|
||||||
|
|
||||||
|
## Transport Layer
|
||||||
|
|
||||||
|
The app supports three transports, all sharing the same command/response protocol:
|
||||||
|
|
||||||
|
| Transport | Method | Implementation |
|
||||||
|
|---|---|---|
|
||||||
|
| Bluetooth LE | Nordic UART Service (NUS) GATT | `flutter_blue_plus` |
|
||||||
|
| USB Serial | Packet-framed serial | `MeshCoreUsbManager` |
|
||||||
|
| TCP | Packet-framed socket | `MeshCoreTcpConnector` |
|
||||||
|
|
||||||
|
### BLE (Nordic UART Service)
|
||||||
|
|
||||||
|
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
|
||||||
|
- **RX Characteristic** (write to device): `6e400002-b5a3-f393-e0a9-e50e24dcca9e`
|
||||||
|
- **TX Characteristic** (notify from device): `6e400003-b5a3-f393-e0a9-e50e24dcca9e`
|
||||||
|
|
||||||
|
Raw `Uint8List` payloads are written directly to the RX characteristic. Writes use "write without response" if supported, falling back to "write with response".
|
||||||
|
|
||||||
|
### USB and TCP Framing
|
||||||
|
|
||||||
|
Both use a lightweight packet framing codec:
|
||||||
|
|
||||||
|
```
|
||||||
|
TX (host → device): [0x3C][len_lo][len_hi][payload...]
|
||||||
|
RX (device → host): [0x3E][len_lo][len_hi][payload...]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Frame start: `0x3C` (`<`) for outgoing, `0x3E` (`>`) for incoming
|
||||||
|
- Length: 2-byte little-endian, payload only
|
||||||
|
- Max payload: 172 bytes
|
||||||
|
- TCP: `tcpNoDelay: true` (Nagle disabled), writes serialized to prevent interleaving
|
||||||
|
- USB: 10ms post-write delay between frames
|
||||||
|
|
||||||
|
## Connection State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
enum MeshCoreConnectionState {
|
||||||
|
disconnected,
|
||||||
|
scanning,
|
||||||
|
connecting,
|
||||||
|
connected,
|
||||||
|
disconnecting,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## BLE Connection Lifecycle
|
||||||
|
|
||||||
|
1. **Scan** with keyword filters `["MeshCore-", "Whisper-"]`
|
||||||
|
2. **Connect** with 15-second timeout
|
||||||
|
3. **Request MTU** 185 bytes (non-web only)
|
||||||
|
4. **Discover services** and locate NUS
|
||||||
|
5. **Enable TX notifications** (up to 3 attempts on native)
|
||||||
|
6. **Subscribe** to TX characteristic for incoming frames
|
||||||
|
7. **Initial sync**: device info query, time sync, channel sync
|
||||||
|
|
||||||
|
## Auto-Reconnect (BLE Only)
|
||||||
|
|
||||||
|
On unexpected disconnection, auto-reconnect with exponential backoff:
|
||||||
|
- Delays: 1s, 2s, 4s, 8s, 16s, 30s, 30s...
|
||||||
|
- Resets on successful connection
|
||||||
|
- Disabled for manual disconnects
|
||||||
|
- Not available for USB or TCP
|
||||||
|
|
||||||
|
## Protocol Constants
|
||||||
|
|
||||||
|
| Constant | Value | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Max frame size | 172 bytes | BLE/USB/TCP payload limit |
|
||||||
|
| Public key size | 32 bytes | Ed25519 public key |
|
||||||
|
| Max path size | 64 bytes | Maximum path data |
|
||||||
|
| Max name size | 32 bytes | Maximum node name |
|
||||||
|
| Max text payload | 160 bytes | Firmware `MAX_TEXT_LEN` |
|
||||||
|
| App protocol version | 3 | Sent in device query |
|
||||||
|
| Contact frame size | 148 bytes | Fixed-size contact record |
|
||||||
|
|
||||||
|
## Command Codes (App → Device)
|
||||||
|
|
||||||
|
| Code | Name | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 1 | CMD_APP_START | Announce app connection |
|
||||||
|
| 2 | CMD_SEND_TXT_MSG | Send direct text message |
|
||||||
|
| 3 | CMD_SEND_CHANNEL_TXT_MSG | Send channel text message |
|
||||||
|
| 4 | CMD_GET_CONTACTS | Request contact list |
|
||||||
|
| 5 | CMD_GET_DEVICE_TIME | Query device clock |
|
||||||
|
| 6 | CMD_SET_DEVICE_TIME | Set device clock |
|
||||||
|
| 7 | CMD_SEND_SELF_ADVERT | Broadcast own advertisement |
|
||||||
|
| 8 | CMD_SET_ADVERT_NAME | Set node name |
|
||||||
|
| 9 | CMD_ADD_UPDATE_CONTACT | Add or update a contact |
|
||||||
|
| 10 | CMD_SYNC_NEXT_MESSAGE | Request next queued message |
|
||||||
|
| 11 | CMD_SET_RADIO_PARAMS | Set radio parameters |
|
||||||
|
| 12 | CMD_SET_RADIO_TX_POWER | Set TX power |
|
||||||
|
| 13 | CMD_RESET_PATH | Reset contact path |
|
||||||
|
| 14 | CMD_SET_ADVERT_LATLON | Set advertised location |
|
||||||
|
| 15 | CMD_REMOVE_CONTACT | Remove a contact |
|
||||||
|
| 16 | CMD_SHARE_CONTACT | Share contact to mesh |
|
||||||
|
| 17 | CMD_EXPORT_CONTACT | Export contact as bytes |
|
||||||
|
| 18 | CMD_IMPORT_CONTACT | Import contact from bytes |
|
||||||
|
| 19 | CMD_REBOOT | Reboot device |
|
||||||
|
| 20 | CMD_GET_BATT_AND_STORAGE | Query battery and storage |
|
||||||
|
| 22 | CMD_DEVICE_QUERY | Query device info |
|
||||||
|
| 26 | CMD_SEND_LOGIN | Login to repeater/room |
|
||||||
|
| 27 | CMD_SEND_STATUS_REQ | Request repeater status |
|
||||||
|
| 30 | CMD_GET_CONTACT_BY_KEY | Get contact by public key |
|
||||||
|
| 31 | CMD_GET_CHANNEL | Get channel definition |
|
||||||
|
| 32 | CMD_SET_CHANNEL | Set channel name and PSK |
|
||||||
|
| 36 | CMD_SEND_TRACE_PATH | Request path trace |
|
||||||
|
| 38 | CMD_SET_OTHER_PARAMS | Set misc parameters |
|
||||||
|
| 39 | CMD_GET_TELEMETRY_REQ | Request sensor telemetry |
|
||||||
|
| 40 | CMD_GET_CUSTOM_VAR | Get custom variables |
|
||||||
|
| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable |
|
||||||
|
| 50 | CMD_SEND_BINARY_REQ | Send binary request |
|
||||||
|
| 57 | CMD_SEND_ANON_REQ | Send anonymous request |
|
||||||
|
| 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration |
|
||||||
|
| 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration |
|
||||||
|
|
||||||
|
## Response / Push Codes (Device → App)
|
||||||
|
|
||||||
|
| Code | Name | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 0 | RESP_CODE_OK | Generic success |
|
||||||
|
| 1 | RESP_CODE_ERR | Generic error |
|
||||||
|
| 2 | RESP_CODE_CONTACTS_START | Contact list begins |
|
||||||
|
| 3 | RESP_CODE_CONTACT | Single contact data |
|
||||||
|
| 4 | RESP_CODE_END_OF_CONTACTS | Contact list complete |
|
||||||
|
| 5 | RESP_CODE_SELF_INFO | Device self-info response |
|
||||||
|
| 6 | RESP_CODE_SENT | Message transmitted; carries `[1]=is_flood, [2–5]=ack_hash, [6–9]=estimated_timeout_ms` |
|
||||||
|
| 7 | RESP_CODE_CONTACT_MSG_RECV | Incoming direct message (v2) |
|
||||||
|
| 8 | RESP_CODE_CHANNEL_MSG_RECV | Incoming channel message (v2) |
|
||||||
|
| 10 | RESP_CODE_NO_MORE_MESSAGES | No more queued messages |
|
||||||
|
| 11 | RESP_CODE_EXPORT_CONTACT | Exported contact data |
|
||||||
|
| 9 | RESP_CODE_CURR_TIME | Current device time |
|
||||||
|
| 12 | RESP_CODE_BATT_AND_STORAGE | Battery mV (uint16 LE) + storage used/total (uint32 LE each) |
|
||||||
|
| 13 | RESP_CODE_DEVICE_INFO | Firmware info |
|
||||||
|
| 16 | RESP_CODE_CONTACT_MSG_RECV_V3 | Incoming direct message (v3) |
|
||||||
|
| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) |
|
||||||
|
| 18 | RESP_CODE_CHANNEL_INFO | Channel definition |
|
||||||
|
| 21 | RESP_CODE_CUSTOM_VARS | Custom variables |
|
||||||
|
| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags |
|
||||||
|
| 0x80 | PUSH_CODE_ADVERT | Known contact re-seen |
|
||||||
|
| 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact |
|
||||||
|
| 0x82 | PUSH_CODE_SEND_CONFIRMED | Delivery ACK from remote; carries ACK hash (4 bytes) + trip time (4 bytes) |
|
||||||
|
| 0x83 | PUSH_CODE_MSG_WAITING | Offline messages queued |
|
||||||
|
| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Repeater/room login succeeded |
|
||||||
|
| 0x86 | PUSH_CODE_LOGIN_FAIL | Repeater/room login failed |
|
||||||
|
| 0x87 | PUSH_CODE_STATUS_RESPONSE | Repeater status response |
|
||||||
|
| 0x88 | PUSH_CODE_LOG_RX_DATA | Radio RX data with SNR (int8, units 1/4 dB), RSSI, and raw radio packet |
|
||||||
|
| 0x89 | PUSH_CODE_TRACE_DATA | Path trace result |
|
||||||
|
| 0x8A | PUSH_CODE_NEW_ADVERT | New node discovered |
|
||||||
|
| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Sensor telemetry data |
|
||||||
|
| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary data response |
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Contact
|
||||||
|
32-byte public key (primary identity), name, type (chat/repeater/room/sensor), flags, path data, GPS coordinates, last-seen timestamp. Parsed from 148-byte firmware frames with this layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
[0] = resp_code
|
||||||
|
[1–32] = public key (32 bytes)
|
||||||
|
[33] = type (1=chat, 2=repeater, 3=room, 4=sensor)
|
||||||
|
[34] = flags (bit 0 = favorite)
|
||||||
|
[35] = path_length
|
||||||
|
[36–99] = path (64 bytes)
|
||||||
|
[100–131] = name (32 bytes, null-padded)
|
||||||
|
[132–135] = timestamp (uint32 LE)
|
||||||
|
[136–139] = latitude (int32 LE, × 1e-6 degrees)
|
||||||
|
[140–143] = longitude (int32 LE, × 1e-6 degrees)
|
||||||
|
[144–147] = last_modified (uint32 LE)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message (Direct)
|
||||||
|
Sender key, text, timestamp, outgoing flag, status (pending/sent/delivered/failed), message ID (UUID), retry count, ACK hash, trip time, path data, reactions.
|
||||||
|
|
||||||
|
### Channel Message
|
||||||
|
Sender name, text, timestamp, status (pending/sent/failed), repeater hops, path variants, channel index, reactions, reply threading fields.
|
||||||
|
|
||||||
|
### Channel
|
||||||
|
Index (0–7), name, 16-byte PSK, unread count. PSK derivation methods for hashtag (SHA-256) and community (HMAC-SHA256) channels.
|
||||||
|
|
||||||
|
### Community
|
||||||
|
UUID, name, 32-byte secret, hashtag channel list. Shared via QR code.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
All data is stored via `SharedPreferences` (JSON-serialized). No SQLite or other database.
|
||||||
|
|
||||||
|
| Data | Storage Key Pattern | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| Contacts | `contacts<pubKey10>` | Per device identity |
|
||||||
|
| Messages | `messages_<pubKey10><contactKey>` | Per device + contact |
|
||||||
|
| Channel Messages | `channel_messages_<pubKey10><index>` | Per device + channel |
|
||||||
|
| Channels | `channels<pubKey10>` | Per device identity |
|
||||||
|
| Channel Order | `channel_order_<pubKey10>` | Per device identity |
|
||||||
|
| Contact Groups | `contact_groups<pubKey10>` | Per device identity |
|
||||||
|
| Communities | `communities_v1<pubKey10>` | Per device identity |
|
||||||
|
| Unread Counts | `contact_unread_count<pubKey10>` | Per device identity |
|
||||||
|
| Discovered Contacts | `discovered_contacts` | Global |
|
||||||
|
| App Settings | `app_settings` | Global |
|
||||||
|
| Path History | `path_history_<contactKey>` | Per contact |
|
||||||
|
|
||||||
|
## Auto-Add Configuration Bitmask
|
||||||
|
|
||||||
|
Used by `CMD_SET_AUTO_ADD_CONFIG` (58) and `RESP_CODE_AUTO_ADD_CONFIG` (25):
|
||||||
|
|
||||||
|
| Bit | Flag | Description |
|
||||||
|
|-----|------|-------------|
|
||||||
|
| 0 | 0x01 | Overwrite oldest contact when list is full |
|
||||||
|
| 1 | 0x02 | Auto-add chat users |
|
||||||
|
| 2 | 0x04 | Auto-add repeaters |
|
||||||
|
| 3 | 0x08 | Auto-add room servers |
|
||||||
|
| 4 | 0x10 | Auto-add sensors |
|
||||||
|
|
||||||
|
## Radio Packet Payload Types
|
||||||
|
|
||||||
|
Seen inside `PUSH_CODE_LOG_RX_DATA` raw packets:
|
||||||
|
|
||||||
|
| Code | Type |
|
||||||
|
|------|------|
|
||||||
|
| 0x00 | REQ (request) |
|
||||||
|
| 0x01 | RESPONSE |
|
||||||
|
| 0x02 | TXTMSG (text message) |
|
||||||
|
| 0x03 | ACK |
|
||||||
|
| 0x04 | ADVERT |
|
||||||
|
| 0x05 | GRPTXT (group/channel text) |
|
||||||
|
| 0x06 | GRPDATA (group data) |
|
||||||
|
| 0x07 | ANONREQ (anonymous request) |
|
||||||
|
| 0x08 | PATH |
|
||||||
|
| 0x09 | TRACE |
|
||||||
|
| 0x0A | MULTIPART |
|
||||||
|
| 0x0B | CONTROL |
|
||||||
|
| 0x0F | RAW_CUSTOM |
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
Uses Flutter `Provider` with `ChangeNotifier`. The central state holder is `MeshCoreConnector`, which owns all in-memory collections and fires debounced (50ms) `notifyListeners()` to update the UI. In-memory conversations are windowed to 200 messages per contact; older messages remain on disk and are loaded on demand.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. Raw frames arrive over BLE/USB/TCP
|
||||||
|
2. First byte is parsed as response/push code
|
||||||
|
3. Appropriate model factory (`fromFrame()`) parses the data
|
||||||
|
4. In-memory collections are updated
|
||||||
|
5. Storage stores are persisted (async)
|
||||||
|
6. `notifyListeners()` triggers UI rebuilds
|
||||||
|
7. Screens read current state via getters
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Channels
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh.
|
||||||
|
|
||||||
|
Up to 8 channels (indices 0–7) can be active simultaneously on one device.
|
||||||
|
|
||||||
|
## How to Access
|
||||||
|
|
||||||
|
QuickSwitchBar tab 1 (middle) from any main screen.
|
||||||
|
|
||||||
|
## Channel Types
|
||||||
|
|
||||||
|
| Type | Icon | Color | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Public | Globe | Green | Fixed well-known PSK; any device can join |
|
||||||
|
| Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention |
|
||||||
|
| Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key |
|
||||||
|
| Community | Groups/Tag | Purple | PSK derived via HMAC-SHA256 from a community's shared secret |
|
||||||
|
|
||||||
|
## Channels List Screen
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
- **Search bar** with live text filtering (300ms debounce)
|
||||||
|
- **Sort/filter button**
|
||||||
|
- **Scrollable list of channel cards**, each showing:
|
||||||
|
- Type icon with color coding (purple badge overlay for community channels)
|
||||||
|
- Channel name (or "Channel N" if unnamed)
|
||||||
|
- Subtitle: "Public channel", "Hashtag channel", "Private channel", or "Community channel - {name}"
|
||||||
|
- Unread badge (if messages are unread)
|
||||||
|
- Drag handle (when manual sort is active)
|
||||||
|
- **"+" FAB** to add a new channel
|
||||||
|
- **Overflow menu**: Disconnect, Manage Communities (only shown when at least one community exists), Settings
|
||||||
|
|
||||||
|
If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown.
|
||||||
|
|
||||||
|
Pull-to-refresh (swipe down) forces a re-fetch of channels from the device firmware.
|
||||||
|
|
||||||
|
### Sorting Options
|
||||||
|
|
||||||
|
- **Manual** (default): Drag-and-drop reordering, persisted (drag handles are hidden when a search query is active)
|
||||||
|
- **A–Z**: Alphabetical
|
||||||
|
- **Latest messages**: Most recent first
|
||||||
|
- **Unread**: Most unread first
|
||||||
|
|
||||||
|
## Adding a Channel
|
||||||
|
|
||||||
|
Tap the "+" FAB to open a dialog with six options:
|
||||||
|
|
||||||
|
1. **Create Private Channel** — Enter a name (max 31 characters); a random PSK is generated
|
||||||
|
2. **Join Private Channel** — Enter a name and a 32-hex PSK (non-hex characters like spaces and dashes are silently stripped, so pasted keys with formatting are accepted)
|
||||||
|
3. **Join Public Channel** — One tap; uses the well-known public PSK (only shown if no public channel exists)
|
||||||
|
4. **Join Hashtag Channel** — Enter a hashtag name; PSK is derived from the name. If communities exist, choose between regular hashtag (SHA-256) or community hashtag (HMAC)
|
||||||
|
5. **Scan Community QR** — Opens QR scanner to join a community
|
||||||
|
6. **Create Community** — Enter a name; generates a random 32-byte secret; optionally adds a community public channel; shows QR code for sharing
|
||||||
|
|
||||||
|
## Channel Actions (Long-Press / Right-Click)
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|---|---|
|
||||||
|
| Edit | Change name, PSK (with a dice icon to generate a random PSK), or SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit) |
|
||||||
|
| Mute / Unmute | Toggle push notification suppression for this channel |
|
||||||
|
| Delete | Remove the channel from the device (confirmation required) |
|
||||||
|
|
||||||
|
## Channel Chat
|
||||||
|
|
||||||
|
Tap a channel card to open the channel chat screen.
|
||||||
|
|
||||||
|
### App Bar
|
||||||
|
|
||||||
|
- Type icon (public/private/hashtag)
|
||||||
|
- Channel name
|
||||||
|
- Subtitle: "{type} - {N} unread"
|
||||||
|
|
||||||
|
### Message Display
|
||||||
|
|
||||||
|
- Reverse-scrolling list (newest at bottom)
|
||||||
|
- **Incoming messages**: Colored avatar with sender's initial (or first emoji if name starts with one; color is deterministic from sender name hash), sender name in primary color, message bubble
|
||||||
|
- **Outgoing messages**: Primary container color bubble with a small status icon: pending (clock), sent (checkmark), or failed (red error circle)
|
||||||
|
- Automatic older-message loading on scroll-to-top
|
||||||
|
- Jump-to-bottom button when scrolled up
|
||||||
|
- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset text size
|
||||||
|
- **Message tracing mode** (when enabled in App Settings): Each bubble additionally shows path prefix bytes (`via XX,YY,...`), a timestamp, and a repeat count icon
|
||||||
|
|
||||||
|
### Message Types in Chat
|
||||||
|
|
||||||
|
- **Plain text** with linkified URLs
|
||||||
|
- **GIFs** (`g:{gifId}`) rendered inline via Giphy CDN
|
||||||
|
- **Location pins** (`m:{lat},{lon}|{label}|`) shown as tappable location cards
|
||||||
|
- **Reactions** displayed as emoji pills below target messages
|
||||||
|
|
||||||
|
### Replies (Channel Chat Only)
|
||||||
|
|
||||||
|
- **Mobile**: Swipe an **incoming** message left to trigger reply (with haptic feedback). You cannot swipe your own outgoing messages. Swipe reply is not available on desktop.
|
||||||
|
- **All platforms**: Long-press → "Reply"
|
||||||
|
- Reply banner appears above the input bar with the quoted message (tap X to cancel)
|
||||||
|
- Sent replies are prefixed `@[{senderName}] {text}`
|
||||||
|
- Received replies show a bordered quote block inside the bubble; tapping scrolls to the original. Reply previews render GIF thumbnails and location pin icons, not just text.
|
||||||
|
|
||||||
|
### Message Path Viewing
|
||||||
|
|
||||||
|
- **Mobile**: Tap a message bubble to view its routing path
|
||||||
|
- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop)
|
||||||
|
- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md))
|
||||||
|
|
||||||
|
### Context Actions (Long-Press / Right-Click)
|
||||||
|
|
||||||
|
| Action | Availability | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Reply | All messages | Triggers reply mode |
|
||||||
|
| Path | Desktop only | Opens message path view |
|
||||||
|
| Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) |
|
||||||
|
| Copy | All messages | Copies text to clipboard |
|
||||||
|
| Delete | All messages | Removes locally (not from mesh) |
|
||||||
|
|
||||||
|
### Message Path Viewing
|
||||||
|
|
||||||
|
Tap a message bubble to open the Channel Message Path Screen, which shows:
|
||||||
|
- Each hop in the path as a visual chain
|
||||||
|
- Known contacts identified by name at each hop
|
||||||
|
- Observed vs. declared hop counts
|
||||||
|
- Alternative path variants (if received via multiple paths)
|
||||||
|
- Map view buttons for geographic path visualization
|
||||||
|
|
||||||
|
## Communities
|
||||||
|
|
||||||
|
Communities are a layer above channels that provide a private namespace.
|
||||||
|
|
||||||
|
### What is a Community?
|
||||||
|
|
||||||
|
A community has a name and a 32-byte random secret. Channel PSKs are derived from this secret:
|
||||||
|
- **Public channel**: `HMAC-SHA256(secret, "channel:v1:__public__")[:16]`
|
||||||
|
- **Hashtag channel**: `HMAC-SHA256(secret, "channel:v1:{hashtag}")[:16]`
|
||||||
|
|
||||||
|
Outsiders who don't know the secret cannot discover or join community channels.
|
||||||
|
|
||||||
|
### Sharing a Community
|
||||||
|
|
||||||
|
Communities are shared via QR codes containing a JSON payload:
|
||||||
|
```json
|
||||||
|
{"v": 1, "type": "meshcore_community", "name": "...", "k": "<base64url-secret>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Communities
|
||||||
|
|
||||||
|
From the channels screen overflow menu → "Manage Communities". Opens a draggable scrollable sheet (resizable 30–90% of screen height):
|
||||||
|
|
||||||
|
- Each community shows its name and a short community ID (first 8 hex characters)
|
||||||
|
- **Tap a community** to directly show its QR code for sharing
|
||||||
|
- **Popup menu** per community:
|
||||||
|
- **Show QR** — displays the QR code for sharing with new members
|
||||||
|
- **Delete** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed)
|
||||||
|
|
||||||
|
## How Channels Differ from Direct Messages
|
||||||
|
|
||||||
|
| Aspect | Channels | Direct Messages |
|
||||||
|
|---|---|---|
|
||||||
|
| Addressing | Broadcast to all nodes with matching PSK | Point-to-point to a specific contact |
|
||||||
|
| Encryption | Shared PSK (symmetric) | Contact's public key (asymmetric) |
|
||||||
|
| Sender identity | Plain text prefix in payload | Verified via public key |
|
||||||
|
| Replies | Supported (swipe or long-press) | Not supported |
|
||||||
|
| Retry mechanism | No automatic retry | Exponential backoff with path rotation |
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Chat & Messaging
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The app supports two chat modes:
|
||||||
|
- **Direct messages**: Encrypted point-to-point messages to individual contacts
|
||||||
|
- **Channel messages**: Broadcast messages to shared channels (see [Channels](channels.md))
|
||||||
|
|
||||||
|
This page covers direct messaging. For channel chat, see the Channels documentation.
|
||||||
|
|
||||||
|
## How to Access
|
||||||
|
|
||||||
|
From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
|
||||||
|
|
||||||
|
## Chat Screen Layout
|
||||||
|
|
||||||
|
### App Bar
|
||||||
|
|
||||||
|
- **Title**: Contact name
|
||||||
|
- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details.
|
||||||
|
- **Action buttons**:
|
||||||
|
- **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing
|
||||||
|
- **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries.
|
||||||
|
- **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle
|
||||||
|
|
||||||
|
### Message List
|
||||||
|
|
||||||
|
- Scrollable list with newest messages at the bottom
|
||||||
|
- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background
|
||||||
|
- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name)
|
||||||
|
- Bubble width capped at 65% of screen width
|
||||||
|
- Hyperlinks rendered as tappable green underlined text
|
||||||
|
- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset
|
||||||
|
- **Jump to bottom**: Floating button appears when scrolled away from the bottom
|
||||||
|
- **Lazy loading**: Scrolling to top loads older messages from storage
|
||||||
|
|
||||||
|
### Input Bar
|
||||||
|
|
||||||
|
- **GIF button** (left): Opens GIF picker bottom sheet
|
||||||
|
- **Text field** (center): Auto-capitalization, enforces UTF-8 byte limit in real-time
|
||||||
|
- **Send button** (right): Submits the message
|
||||||
|
- On desktop: Enter/Numpad Enter also submits
|
||||||
|
- When a GIF is selected, the text field shows an inline GIF preview with a dismiss button
|
||||||
|
|
||||||
|
## Message Types
|
||||||
|
|
||||||
|
| Type | Wire Format | Display |
|
||||||
|
|---|---|---|
|
||||||
|
| Plain text | Raw UTF-8 string | Inline text with link detection |
|
||||||
|
| GIF | `g:<giphy-id>` | Inline GIF image from Giphy CDN |
|
||||||
|
| Location pin | `m:<lat>,<lon>\|<label>\|...` | Location icon + label; tap to open map |
|
||||||
|
| Reaction | `r:<hash>:<emoji-index>` | Applied to target message as emoji pill |
|
||||||
|
|
||||||
|
## Message Status
|
||||||
|
|
||||||
|
Outgoing messages display a status indicator:
|
||||||
|
|
||||||
|
| Status | Icon | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| Pending | Grey double-check | Queued, waiting for device to transmit (visually identical to Sent) |
|
||||||
|
| Sent | Grey double-check | Device confirmed transmission (visually identical to Pending) |
|
||||||
|
| Delivered | Green double-check | Remote node acknowledged receipt |
|
||||||
|
| Failed | Red X | All retries exhausted |
|
||||||
|
|
||||||
|
### Message Tracing Mode
|
||||||
|
|
||||||
|
When enabled in App Settings, additional metadata appears inside each bubble:
|
||||||
|
- Timestamp (HH:MM)
|
||||||
|
- Retry count (e.g., "Retry 2 of 4")
|
||||||
|
- Status icon
|
||||||
|
- Round-trip time in seconds (if delivered)
|
||||||
|
|
||||||
|
## Message Length Limits
|
||||||
|
|
||||||
|
- **Direct messages**: 156 bytes (UTF-8) — enforced in real-time by the input formatter
|
||||||
|
- **Channel messages**: 160 minus sender name length minus 2 bytes for the `"<name>: "` prefix
|
||||||
|
- Over-length paste shows a snackbar error
|
||||||
|
|
||||||
|
## Send Queue
|
||||||
|
|
||||||
|
Only one message per contact can be in-flight at a time (to avoid overflowing the firmware's 8-entry ACK table). If you send multiple messages rapidly, they are queued and sent sequentially — each waits for the previous one to be delivered, fail, or exhaust retries before transmitting.
|
||||||
|
|
||||||
|
## Retry Mechanism
|
||||||
|
|
||||||
|
When a direct message is sent:
|
||||||
|
|
||||||
|
1. The app computes an expected ACK hash: `SHA256([timestamp][attempt][text][selfPubKey])[0:4]` — matching the firmware's hash calculation. If SMAZ compression is enabled, the compressed text (not the original) is hashed
|
||||||
|
2. On device acknowledgment (`RESP_CODE_SENT`), the message transitions to "sent" and a timeout timer starts
|
||||||
|
3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise `3000 + 3000 × path_length` milliseconds (15000ms for flood)
|
||||||
|
4. On timeout, the message is retried with **exponential backoff**: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s, 16s...)
|
||||||
|
5. **Max retries**: Configurable (default 5, range 2–10)
|
||||||
|
6. After max retries, the message is marked "failed" — but a **30-second grace window** remains during which a late ACK can still resolve the message to "delivered"
|
||||||
|
7. If **Clear Path on Max Retry** is enabled (App Settings), the contact's stored routing path is automatically cleared when max retries are exhausted
|
||||||
|
8. **Auto route rotation**: When enabled (and no manual path override is set), the retry service uses a diversity window to avoid re-using recently tried paths, cycling through known routes on each attempt
|
||||||
|
|
||||||
|
### Manual Retry
|
||||||
|
|
||||||
|
Long-press a failed message → "Retry" to re-send using the current routing settings.
|
||||||
|
|
||||||
|
## Reactions
|
||||||
|
|
||||||
|
Add emoji reactions to incoming messages (not your own):
|
||||||
|
|
||||||
|
1. Long-press (or right-click on desktop) a message
|
||||||
|
2. Select "Add reaction" from the context menu
|
||||||
|
3. Choose from quick emojis (thumbs up, heart, laugh, party, clap, fire) or browse the full emoji picker
|
||||||
|
4. Reactions appear as pills below the message bubble with emoji and count
|
||||||
|
5. Pending reactions show at 50% opacity with a spinner
|
||||||
|
6. Failed reactions show a red retry icon (tap to retry)
|
||||||
|
|
||||||
|
## Context Actions (Long-Press / Right-Click)
|
||||||
|
|
||||||
|
| Action | Availability | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Add reaction | Incoming messages only | Opens emoji picker |
|
||||||
|
| View path | Mobile: tap bubble directly; Desktop: long-press/right-click menu | Shows message routing path |
|
||||||
|
| Copy | All messages | Copies text to clipboard |
|
||||||
|
| Delete | All messages | Removes locally (not from mesh) |
|
||||||
|
| Retry | Failed outgoing messages | Re-sends the message |
|
||||||
|
| Open chat with sender | Room server chats | Opens 1:1 chat with the message sender |
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Contacts
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Contacts screen is the primary hub for managing mesh nodes your radio has a relationship with. A "contact" is any node whose cryptographic advertisement has been received — it can be a chat user, repeater, room server, or sensor.
|
||||||
|
|
||||||
|
## How to Access
|
||||||
|
|
||||||
|
- Automatically shown after connecting to a device
|
||||||
|
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens
|
||||||
|
- Back navigation from Chat or Settings screens
|
||||||
|
|
||||||
|
## Contact Types
|
||||||
|
|
||||||
|
| Type | Avatar Color | Icon | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Chat | Blue | Chat bubble | Another user's mesh radio |
|
||||||
|
| Repeater | Orange | Cell tower | A mesh repeater/relay node |
|
||||||
|
| Room | Purple | Group | A room server for group chat |
|
||||||
|
| Sensor | Green | Sensors | A sensor device |
|
||||||
|
|
||||||
|
## Contact List
|
||||||
|
|
||||||
|
Each contact is displayed as a list tile showing:
|
||||||
|
|
||||||
|
- **Avatar**: Color-coded circle with type icon (or first emoji of the contact's name if it starts with one)
|
||||||
|
- **Name**: Contact name (single line)
|
||||||
|
- **Path label**: "Direct", "N hops", or "Flood" (with forced variants if a path override is active)
|
||||||
|
- **Public key**: Shortened hex format `<XXXXXXXX...XXXXXXXX>`
|
||||||
|
- **Unread badge**: Red pill with count (if unread messages exist)
|
||||||
|
- **Last seen**: Relative timestamp ("Now", "5 mins ago", "2 hours ago", "3 days ago"). For chat contacts, this shows whichever is more recent: the last advertisement time or the last message time
|
||||||
|
- **Favorite star**: Amber star icon if favorited
|
||||||
|
- **Location pin**: Grey pin icon if the contact has GPS coordinates
|
||||||
|
|
||||||
|
Pull-to-refresh re-fetches the full contact list from the device.
|
||||||
|
|
||||||
|
## Search and Filter
|
||||||
|
|
||||||
|
A toolbar at the top provides:
|
||||||
|
|
||||||
|
**Search**: Matches contact name (case-insensitive) or public key hex prefix. Debounced at 300ms.
|
||||||
|
|
||||||
|
**Sort options**:
|
||||||
|
- Latest Messages (by most recent message)
|
||||||
|
- Heard Recently (by last seen / last message)
|
||||||
|
- A–Z (alphabetical)
|
||||||
|
|
||||||
|
**Filter options**:
|
||||||
|
- All, Favorites, Users, Repeaters, Room Servers, Unread Only
|
||||||
|
|
||||||
|
## Contact Groups
|
||||||
|
|
||||||
|
Groups are a client-side organizational feature for grouping contacts.
|
||||||
|
|
||||||
|
- **Create a group**: Tap the group dropdown → "+" icon → enter name → select members → Save
|
||||||
|
- **Edit a group**: Group dropdown → pencil icon next to the group
|
||||||
|
- **Delete a group**: Group dropdown → trash icon next to the group
|
||||||
|
- **Filter by group**: Select a group from the dropdown to show only its members
|
||||||
|
|
||||||
|
Groups are stored per radio identity (scoped by public key).
|
||||||
|
|
||||||
|
**Validation rules**: Group names cannot be empty, cannot be "all" (reserved, case-insensitive), and must be unique (case-insensitive). The group creation dialog includes a built-in search field to filter contacts when selecting members. Creating a new group automatically selects it as the active filter.
|
||||||
|
|
||||||
|
## Tap Actions
|
||||||
|
|
||||||
|
| Contact Type | Action on Tap |
|
||||||
|
|---|---|
|
||||||
|
| Chat / Sensor | Opens ChatScreen for direct messaging |
|
||||||
|
| Repeater | Shows password login dialog → opens RepeaterHubScreen |
|
||||||
|
| Room | Shows password login dialog → opens ChatScreen for room chat |
|
||||||
|
|
||||||
|
## Long-Press / Right-Click Menu
|
||||||
|
|
||||||
|
| Action | Availability | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Path Trace / Ping | Repeaters, Rooms (always); Chat if `pathLength > 0` | Opens PathTraceMapScreen. Label shows "Ping" when no path bytes are known, "Path Trace" otherwise |
|
||||||
|
| Manage Repeater | Repeaters only | Login dialog → RepeaterHubScreen |
|
||||||
|
| Room Login | Rooms only | Login dialog → ChatScreen |
|
||||||
|
| Room Management | Rooms only | Login dialog → RepeaterHubScreen (management mode) |
|
||||||
|
| Open Chat | Chat/Sensor | Same as single tap |
|
||||||
|
| Add/Remove Favorite | All types | Toggles the favorite flag |
|
||||||
|
| Share Contact | All types | Copies `meshcore://<hex>` URI to clipboard |
|
||||||
|
| Share Contact Zero-Hop | All types | Broadcasts the contact's advertisement one hop |
|
||||||
|
| Delete Contact | All types | Confirmation dialog → removes from device and clears messages |
|
||||||
|
|
||||||
|
## App Bar Menus
|
||||||
|
|
||||||
|
The Contacts screen has **two separate popup menus** in the app bar:
|
||||||
|
|
||||||
|
**Antenna icon menu** (contact sharing):
|
||||||
|
- Zero-Hop Advert — broadcasts your advertisement to immediately adjacent nodes
|
||||||
|
- Flood Advert — broadcasts across the full mesh network
|
||||||
|
- Copy Advert to Clipboard — copies your `meshcore://<hex>` URI for sharing externally
|
||||||
|
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it
|
||||||
|
|
||||||
|
**Three-dot overflow menu**:
|
||||||
|
- Disconnect — disconnects from the device
|
||||||
|
- Discovered Contacts — opens the DiscoveryScreen
|
||||||
|
- Settings — opens the Settings screen
|
||||||
|
|
||||||
|
## Adding Contacts
|
||||||
|
|
||||||
|
### Automatic (Passive)
|
||||||
|
When the radio hears an advertisement, the contact appears automatically if auto-add is enabled for that type (configurable in Settings → Contact Settings).
|
||||||
|
|
||||||
|
### Import from Clipboard
|
||||||
|
Antenna menu → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device.
|
||||||
|
|
||||||
|
### Import from Discovered Contacts
|
||||||
|
Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Add, Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms, Favorites), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
|
||||||
|
|
||||||
|
## Contact Sharing Format
|
||||||
|
|
||||||
|
Contacts are shared using the `meshcore://` URI scheme:
|
||||||
|
```
|
||||||
|
meshcore://<hex-encoded-advertisement-packet>
|
||||||
|
```
|
||||||
|
This contains the node's public key and metadata. Paste it into another MeshCore app to import.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Map & Location
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Map feature is a full-featured node-location visualization and radio-planning tool built on OpenStreetMap tiles. It is one of the three primary views accessible from the QuickSwitchBar.
|
||||||
|
|
||||||
|
## How to Access
|
||||||
|
|
||||||
|
- **QuickSwitchBar tab 2** (rightmost) from Contacts or Channels
|
||||||
|
- **Deep-link from a chat message**: Tapping a shared location pin in a chat opens the map centered on that pin
|
||||||
|
- **Settings → Offline Map Cache**: Opens the tile cache management screen
|
||||||
|
|
||||||
|
## What the Map Displays
|
||||||
|
|
||||||
|
### Self Location (Teal Circle)
|
||||||
|
Your own node's position, obtained from the device firmware. Displayed as a teal `person_pin_circle` icon. Only appears if the device has GPS data or a manually-set location.
|
||||||
|
|
||||||
|
### Contact / Node Markers (Color-Coded)
|
||||||
|
All contacts with known GPS coordinates are plotted:
|
||||||
|
|
||||||
|
| Type | Color | Icon |
|
||||||
|
|---|---|---|
|
||||||
|
| Chat user | Blue | Person |
|
||||||
|
| Repeater | Green | Router |
|
||||||
|
| Room | Purple | Meeting room |
|
||||||
|
| Sensor | Orange | Sensors |
|
||||||
|
|
||||||
|
Node name labels appear automatically at zoom level 12 and above.
|
||||||
|
|
||||||
|
### Shared Map Pins (Flag Icons)
|
||||||
|
Location pins shared in chat messages are displayed as flags:
|
||||||
|
- **Blue flag**: From a direct message
|
||||||
|
- **Purple flag**: From a private channel
|
||||||
|
- **Orange flag**: From a public channel
|
||||||
|
|
||||||
|
Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent).
|
||||||
|
|
||||||
|
### Predicted / Guessed Locations (Semi-Transparent)
|
||||||
|
|
||||||
|
Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as semi-transparent markers with a `not_listed_location` icon, visually distinct from confirmed-location markers.
|
||||||
|
|
||||||
|
#### Why guessed locations exist
|
||||||
|
|
||||||
|
In a mesh network, every message hops through one or more repeaters on its way to the destination. Each repeater in the path is identified by the first byte of its public key. If any of those repeaters have a known GPS location (because they advertise it), then a contact that routes through those repeaters must be somewhere within radio range of them. By combining the positions of multiple repeaters a contact is known to use, the app can triangulate a rough area where the contact is likely located.
|
||||||
|
|
||||||
|
#### How the algorithm works
|
||||||
|
|
||||||
|
1. **Build a repeater index**: The app collects all known contacts of type Repeater that have a valid GPS position and indexes them by the first byte of their public key.
|
||||||
|
|
||||||
|
2. **Collect anchor points**: For each contact that lacks GPS, the app looks at the **last-hop byte** of the contact's current path and also searches the `PathHistoryService` for recent paths. Each last-hop byte that matches a located repeater becomes an "anchor point" — a GPS coordinate the contact is likely near.
|
||||||
|
|
||||||
|
3. **Resolve ambiguity**: If multiple repeaters share the same first public-key byte (a hash collision), that byte is discarded as ambiguous. Only unambiguous one-to-one matches are kept.
|
||||||
|
|
||||||
|
4. **Filter geometric inconsistencies**: Two anchor points separated by more than `2 × maxRangeKm` (the estimated LoRa radio range, computed from the current frequency, bandwidth, spreading factor, and TX power using a free-space path loss model) cannot both be in range of the same node. Outlier anchors are removed to keep only a geometrically consistent set.
|
||||||
|
|
||||||
|
5. **Compute the estimated position**:
|
||||||
|
- **Single anchor**: The contact is placed on a small circle (330m radius) around the repeater. The angle on the circle is deterministic — derived from an FNV-1a hash of the contact's public key — so the same contact always appears at the same offset, preventing markers from stacking on top of each other.
|
||||||
|
- **Two or more anchors**: The position is the average (centroid) of all anchor coordinates, with a smaller offset radius (80–120m) applied for visual separation.
|
||||||
|
|
||||||
|
6. **Assign confidence level**:
|
||||||
|
- **High confidence** (2+ anchors): Displayed at 55% opacity.
|
||||||
|
- **Low confidence** (1 anchor): Displayed at 30% opacity.
|
||||||
|
|
||||||
|
7. **Cache the result**: The computation is cached using a key derived from the contact's paths, anchor positions, path-history version, and radio parameters. The cache is only invalidated when any of these inputs change, avoiding recomputation on every UI rebuild.
|
||||||
|
|
||||||
|
#### How to read guessed locations on the map
|
||||||
|
|
||||||
|
- **Semi-transparent marker** with a `not_listed_location` icon: This is a guessed position, not a confirmed GPS fix.
|
||||||
|
- **More opaque** (55%): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
|
||||||
|
- **More transparent** (30%): Lower confidence — based on a single repeater anchor only.
|
||||||
|
- Coordinates shown in the marker info dialog are prefixed with `~` to indicate they are estimated.
|
||||||
|
- Guessed locations can be toggled on/off in the map filter dialog (FAB → "Guessed locations" toggle).
|
||||||
|
|
||||||
|
## Map Interactions
|
||||||
|
|
||||||
|
### Zoom and Pan
|
||||||
|
Standard pinch-to-zoom (range 2–18). Initial camera position is calculated from the statistical spread of all plotted points.
|
||||||
|
|
||||||
|
### Tap on a Node Marker
|
||||||
|
Opens a dialog showing: type, path (hop chain), coordinates, last-seen time, and public key. Action buttons vary by type:
|
||||||
|
- **Chat nodes**: "Open Chat"
|
||||||
|
- **Repeaters**: "Manage Repeater"
|
||||||
|
- **Rooms**: "Join Room"
|
||||||
|
|
||||||
|
### Long-Press on Empty Map Area
|
||||||
|
Shows a bottom sheet with:
|
||||||
|
- **Share marker here**: Prompts for a label, then pick a DM contact or channel to send the location to. Wire format: `m:<lat>,<lon>|<label>|poi`
|
||||||
|
- **Set as my location**: Updates your device's advertised location
|
||||||
|
|
||||||
|
### Filter Dialog (FAB)
|
||||||
|
Toggle visibility of: chat nodes, repeaters, other nodes, guessed locations, discovery contacts.
|
||||||
|
Additional filters:
|
||||||
|
- **Key prefix filter**: Show only contacts whose public key starts with a given prefix
|
||||||
|
- **Last-seen time slider**: From 1 hour to "all time"
|
||||||
|
|
||||||
|
### Legend Card (Top-Right)
|
||||||
|
Shows node count and pin count. Tappable to expand a legend of all marker types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path Trace Map
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
- From the main map's radar icon
|
||||||
|
- From a contact's long-press menu → "Path Trace / Ping"
|
||||||
|
- From a message's path view → radar icon
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
A map with a polyline showing the route from your node through repeater hops to the target:
|
||||||
|
- **Green circles**: Hops with known GPS coordinates
|
||||||
|
- **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts)
|
||||||
|
- **Red endpoint**: Target contact with known GPS
|
||||||
|
- **Purple semi-transparent endpoint**: Target with guessed position
|
||||||
|
|
||||||
|
A legend card at the bottom lists each hop pair with SNR quality icons and total path distance.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
Sends a trace request frame over the mesh. The repeater network traces the path hop-by-hop and returns per-hop SNR data. For hops without GPS, positions are inferred by averaging GPS coordinates of contacts sharing that last-hop byte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Line-of-Sight (LOS) Analysis
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
From the main map, tap the terrain/antenna icon.
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
A full-screen map with a collapsible control panel containing:
|
||||||
|
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow)
|
||||||
|
- **Status**: Clear (green) or blocked (red) with distance and minimum clearance
|
||||||
|
- **Options panel**: Node toggles, endpoint dropdowns, antenna height sliders (0–400 ft), Run LOS button
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
- **Long-press the map** to add custom endpoints (orange pushpin markers, renameable/deleteable)
|
||||||
|
- **Tap a marker** to select it as Point A or B; LOS runs automatically when both are set
|
||||||
|
- **Antenna heights** are adjustable for both endpoints
|
||||||
|
- **Map line** between endpoints is colored green (clear) or red (blocked)
|
||||||
|
- Terrain elevation is fetched from the Open-Meteo API (21–81 sample points, cached 24 hours)
|
||||||
|
- K-factor is adjusted per radio frequency from a baseline of 4/3 at 915 MHz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offline Map Cache
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
Settings → App Settings → Map Display → Offline Map Cache
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
- Map with a blue polygon overlay showing previously selected cache bounds
|
||||||
|
- Bounding box coordinates card
|
||||||
|
- **Cache Area** controls: "Use Current View" and Clear buttons
|
||||||
|
- **Zoom Range** slider (3–18) with estimated tile count
|
||||||
|
- **Download progress** bar (when downloading)
|
||||||
|
- **Download Tiles** and **Clear Cache** buttons
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
1. Pan/zoom the map to the desired area
|
||||||
|
2. Tap "Use Current View" to capture the viewport as cache bounds
|
||||||
|
3. Adjust the zoom range slider
|
||||||
|
4. Tap "Download Tiles" (confirmation dialog shows estimated count)
|
||||||
|
5. Tiles are downloaded with up to 8 concurrent connections
|
||||||
|
6. Once cached, tiles are served from disk without internet (365-day stale period)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPX Export
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
Settings → Export section
|
||||||
|
|
||||||
|
### What It Does
|
||||||
|
Exports contacts with GPS coordinates to a `.gpx` file via the OS share sheet. Three export options:
|
||||||
|
- **Export Repeaters**: Repeater and Room contacts with locations
|
||||||
|
- **Export Contacts**: Chat contacts with locations
|
||||||
|
- **Export All**: All contacts with locations
|
||||||
|
|
||||||
|
Each waypoint includes: name, lat/lon, type label, and public key hex.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Location Data Sources
|
||||||
|
|
||||||
|
The phone's own GPS is **never used**. All location data comes from the mesh:
|
||||||
|
|
||||||
|
1. **Device self-location**: Read from firmware device-info response. Set manually in Settings → Location, or updated automatically if the device has a GPS module.
|
||||||
|
2. **Remote node locations**: Extracted from advertisement packets received over the mesh. Encoded as integer lat/lon × 1,000,000.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Navigation
|
||||||
|
|
||||||
|
## App Flow
|
||||||
|
|
||||||
|
The app follows this general flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Contacts Screen
|
||||||
|
```
|
||||||
|
|
||||||
|
After connecting, the three main screens (Contacts, Channels, Map) are accessible via a persistent bottom navigation bar called the **QuickSwitchBar**.
|
||||||
|
|
||||||
|
## Quick Switch Bar
|
||||||
|
|
||||||
|
The QuickSwitchBar is a Material 3 `NavigationBar` with a frosted-glass visual treatment (blur backdrop, transparent theme, rounded corners). It appears at the bottom of all three main screens.
|
||||||
|
|
||||||
|
| Index | Icon | Label | Screen |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | People | Contacts | ContactsScreen |
|
||||||
|
| 1 | Tag | Channels | ChannelsScreen |
|
||||||
|
| 2 | Map | Map | MapScreen |
|
||||||
|
|
||||||
|
Tapping a tab replaces the current screen with a subtle fade + slight horizontal nudge transition (220ms forward, 200ms reverse). The back button is suppressed on all three main screens — navigation between them is flat, not stacked. All icons use outline variants (`people_outline`, `tag`, `map_outlined`) following Material 3 conventions.
|
||||||
|
|
||||||
|
## Device Screen
|
||||||
|
|
||||||
|
The Device Screen is a transitional hub that shows after connection. In practice, the app navigates directly to Contacts after connecting, but the Device Screen is reachable via the QuickSwitchBar.
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
**App Bar**:
|
||||||
|
- Left: Battery indicator chip (tappable — toggles between percentage and voltage display). Icon changes based on level: `battery_unknown` when data unavailable, `battery_alert` (orange) at 15% or below, `battery_full` otherwise
|
||||||
|
- Left-aligned title (`centerTitle: false`): Two-line layout — small grey "MeshCore" label above the device name in bold
|
||||||
|
- Right: Disconnect button (`bluetooth_disabled` crossed-out icon) and Settings button (tune icon)
|
||||||
|
|
||||||
|
**Body**:
|
||||||
|
- **Connection Card**: Device avatar, device name, device ID, "Connected" chip, and battery chip
|
||||||
|
- **Quick Switch** section: The QuickSwitchBar widget for navigating to Contacts/Channels/Map
|
||||||
|
|
||||||
|
### Disconnection
|
||||||
|
|
||||||
|
- The disconnect button shows a confirmation dialog before disconnecting
|
||||||
|
- If the device disconnects unexpectedly, the app automatically navigates back to the Scanner screen (fires after the current frame completes via a post-frame callback)
|
||||||
|
- This auto-navigation behavior (`DisconnectNavigationMixin`) is shared across all main screens
|
||||||
|
|
||||||
|
## Theme and Locale
|
||||||
|
|
||||||
|
- **Theme mode** is user-configurable in App Settings (System / Light / Dark) — not locked to system
|
||||||
|
- **Language** can be overridden to one of 15 supported languages, or follow the system locale
|
||||||
|
- On web, if a non-Chromium browser is detected, the app shows a `ChromeRequiredScreen` instead of the Scanner (Web Bluetooth requires Chromium)
|
||||||
|
|
||||||
|
## Full Navigation Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
ScannerScreen (root, always on stack)
|
||||||
|
├─ [BLE connect] → push → ContactsScreen
|
||||||
|
├─ [TCP FAB] → push → TcpScreen
|
||||||
|
│ └─ [TCP connected] → pushReplacement → ContactsScreen
|
||||||
|
└─ [USB FAB] → push → UsbScreen
|
||||||
|
└─ [USB connected] → pushReplacement → ContactsScreen
|
||||||
|
|
||||||
|
ContactsScreen (selected=0)
|
||||||
|
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
|
||||||
|
├─ [quick-switch 2] → pushReplacement → MapScreen
|
||||||
|
├─ [tap contact] → push → ChatScreen
|
||||||
|
├─ [overflow > Settings] → push → SettingsScreen
|
||||||
|
└─ [overflow > Discovered] → push → DiscoveryScreen
|
||||||
|
|
||||||
|
ChannelsScreen (selected=1)
|
||||||
|
├─ [quick-switch 0] → pushReplacement → ContactsScreen
|
||||||
|
├─ [quick-switch 2] → pushReplacement → MapScreen
|
||||||
|
├─ [tap channel] → push → ChannelChatScreen
|
||||||
|
└─ [overflow > Settings] → push → SettingsScreen
|
||||||
|
|
||||||
|
MapScreen (selected=2)
|
||||||
|
├─ [quick-switch 0] → pushReplacement → ContactsScreen
|
||||||
|
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
|
||||||
|
├─ [radar button] → push → PathTraceMapScreen
|
||||||
|
├─ [terrain button] → push → LineOfSightMapScreen
|
||||||
|
└─ [long-press] → share marker / set location
|
||||||
|
|
||||||
|
Settings (push from any main screen)
|
||||||
|
└─ [App Settings] → push → AppSettingsScreen
|
||||||
|
└─ [Offline Map Cache] → push → MapCacheScreen
|
||||||
|
```
|
||||||
|
|
||||||
|
Any disconnection from any screen triggers `popUntil(route.isFirst)`, returning to the Scanner.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Notifications
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MeshCore Open provides both **system notifications** (push-style OS alerts) and **in-app unread badges** to inform users of new activity.
|
||||||
|
|
||||||
|
## Notification Types
|
||||||
|
|
||||||
|
### 1. Direct Message Notifications
|
||||||
|
- **Triggered when**: A new incoming message arrives from a Chat or Room contact
|
||||||
|
- **Title**: Contact's name
|
||||||
|
- **Body**: Message text (reactions show "Reacted [emoji]", GIFs show "Sent a GIF")
|
||||||
|
- **Priority**: High
|
||||||
|
- **Android channel**: `messages`
|
||||||
|
|
||||||
|
### 2. Channel Message Notifications
|
||||||
|
- **Triggered when**: A new message arrives on a non-muted channel
|
||||||
|
- **Title**: Channel name (or "Channel N" if unnamed)
|
||||||
|
- **Body**: `"<senderName>: <message text>"`
|
||||||
|
- **Priority**: High
|
||||||
|
- **Android channel**: `channel_messages`
|
||||||
|
|
||||||
|
### 3. Advertisement Notifications
|
||||||
|
- **Triggered when**: A new node is discovered on the mesh for the first time
|
||||||
|
- **Title**: "New [type] discovered" (e.g., "New chat node discovered")
|
||||||
|
- **Body**: Contact's name
|
||||||
|
- **Priority**: Default
|
||||||
|
- **Android channel**: `adverts`
|
||||||
|
|
||||||
|
### 4. Background Service Notification (Android Only)
|
||||||
|
- A persistent low-priority notification: "MeshCore running — Keeping BLE connected"
|
||||||
|
- Required by Android for foreground services to keep BLE alive in the background
|
||||||
|
- Tap to re-launch the app
|
||||||
|
- **Does not auto-start on reboot** — the user must re-open the app manually after a phone restart
|
||||||
|
|
||||||
|
### Notification Tap Behavior
|
||||||
|
|
||||||
|
Tapping a notification currently re-launches the app at the root route. It does **not** navigate directly to the relevant chat or channel.
|
||||||
|
|
||||||
|
## In-App Unread Badges
|
||||||
|
|
||||||
|
Red numeric badges appear throughout the UI:
|
||||||
|
- **Contacts list**: Each contact row shows a red pill badge (e.g., "3") for unread messages
|
||||||
|
- **Channels list**: Each channel row shows an unread badge
|
||||||
|
- **Chat screen subtitle**: Shows unread count inline
|
||||||
|
- Badges cap at "99+" for display
|
||||||
|
|
||||||
|
### How Unread Counts Work
|
||||||
|
|
||||||
|
- Stored per contact (by public key) and per channel, **scoped to the connected device's identity** (first 10 hex characters of its public key). Switching between different radios gives each its own independent unread state
|
||||||
|
- **Suppressed when viewing**: Opening a chat resets the count to 0 and cancels the OS notification
|
||||||
|
- **Ignored for**: Outgoing messages, CLI messages, and repeater contacts
|
||||||
|
- Debounced writes (500ms) to avoid excessive storage I/O during message bursts
|
||||||
|
|
||||||
|
## Notification Settings
|
||||||
|
|
||||||
|
Access via **App Settings → Notifications**:
|
||||||
|
|
||||||
|
| Setting | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Enable Notifications | On | Master toggle; requests OS permission when turned on |
|
||||||
|
| Message Notifications | On | DM alerts (greyed out if master is off) |
|
||||||
|
| Channel Message Notifications | On | Channel alerts (greyed out if master is off) |
|
||||||
|
| Advertisement Notifications | On | New node alerts (greyed out if master is off) |
|
||||||
|
|
||||||
|
### Per-Channel Muting
|
||||||
|
|
||||||
|
Long-press a channel in the channels list → "Mute channel" / "Unmute channel". Muted channels do not generate OS notifications.
|
||||||
|
|
||||||
|
There is no per-contact muting.
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
The notification system prevents notification storms:
|
||||||
|
- **Minimum interval**: 3 seconds between individual notifications
|
||||||
|
- **Batch window**: If multiple notifications arrive within 5 seconds, they are combined into a single summary notification on a fourth Android channel (`batch_summary`): "MeshCore Activity — 2 messages, 1 channel message, 3 new nodes". Note: batch summaries are Android-only; on Apple platforms individual notifications are shown
|
||||||
|
|
||||||
|
## Notification Clearing
|
||||||
|
|
||||||
|
- **Opening a contact chat**: Cancels the OS notification and resets unread count
|
||||||
|
- **Opening a channel**: Cancels the channel notification and resets unread count
|
||||||
|
- **Opening Contacts screen**: Cancels all advertisement notifications
|
||||||
|
|
||||||
|
## Platform Support
|
||||||
|
|
||||||
|
| Platform | Message Notifs | Badge | Background Service |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Android | Yes | Via notification number | Yes (foreground service) |
|
||||||
|
| iOS | Yes | Yes (app badge) | No |
|
||||||
|
| macOS | Yes | Yes | No |
|
||||||
|
| Windows | Yes | No | No |
|
||||||
|
| Linux | Yes (if D-Bus available) | No | No |
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Repeater Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Repeater Management provides tools for administering MeshCore repeater and room server nodes. It includes device status monitoring, CLI access, telemetry reading, neighbor discovery, and remote configuration.
|
||||||
|
|
||||||
|
## How to Access
|
||||||
|
|
||||||
|
From the Contacts screen:
|
||||||
|
1. Long-press a **Repeater** or **Room** contact
|
||||||
|
2. Select "Manage Repeater" or "Room Management"
|
||||||
|
3. Enter the admin password in the login dialog
|
||||||
|
4. Navigate to the Repeater Hub Screen
|
||||||
|
|
||||||
|
### Login Dialog
|
||||||
|
|
||||||
|
- Password field with show/hide toggle
|
||||||
|
- "Save password" checkbox (persists for future logins). If a saved password exists, it is pre-filled and the checkbox is pre-checked, making login one-tap
|
||||||
|
- Routing mode selector and "Manage Paths" link are available directly in the dialog (configure routing before login)
|
||||||
|
- Auto-retries up to 5 times on timeout, showing progress ("Attempt 2 of 5"). A wrong password stops immediately after the first attempt — only timeouts trigger retries
|
||||||
|
- After 5 failed attempts, further login attempts are blocked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repeater Hub Screen
|
||||||
|
|
||||||
|
The central management screen showing:
|
||||||
|
|
||||||
|
- **Header card**: Repeater name, short public key, path label, GPS coordinates (if known)
|
||||||
|
- **Battery chemistry selector**: NMC / LiFePO4 / LiPo (saved per repeater)
|
||||||
|
- **Management tool cards** (full-width cards with chevron arrows, not a grid). Title dynamically shows "Repeater Management" or "Room Management" based on contact type:
|
||||||
|
|
||||||
|
| Card | Destination |
|
||||||
|
|---|---|
|
||||||
|
| Status | Repeater Status Screen |
|
||||||
|
| Telemetry | Telemetry Screen |
|
||||||
|
| CLI | Repeater CLI Screen |
|
||||||
|
| Neighbors | Neighbors Screen |
|
||||||
|
| Settings | Repeater Settings Screen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repeater Status
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
Three information cards:
|
||||||
|
|
||||||
|
**System Information**:
|
||||||
|
- Battery percentage
|
||||||
|
- Uptime
|
||||||
|
- Queue length
|
||||||
|
- Error flags
|
||||||
|
- Clock at login time
|
||||||
|
|
||||||
|
**Radio Statistics**:
|
||||||
|
- Last RSSI and SNR
|
||||||
|
- Noise floor
|
||||||
|
- TX and RX airtime
|
||||||
|
|
||||||
|
**Packet Statistics**:
|
||||||
|
- Packets sent, received, and duplicates
|
||||||
|
- Broken down by flood vs. direct
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
- Auto-queries the repeater on open; shows a loading spinner until data arrives
|
||||||
|
- On timeout: red snackbar error. On success: data appears with a green snackbar confirmation
|
||||||
|
- Pull-to-refresh or refresh button to re-query
|
||||||
|
- Routing mode popup and path management dialog in app bar (these controls appear on **all** management sub-screens, not just Status)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repeater CLI
|
||||||
|
|
||||||
|
A terminal-style interface for sending commands directly to the repeater.
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
- **Quick-command bar** (horizontal scroll): Shortcut buttons for common commands (get name, get radio, get tx, neighbors, ver, advert, clock)
|
||||||
|
- **Command history list**: Sent commands in primary color, responses in secondary color
|
||||||
|
- **Input bar**: Up/down history arrows, monospace text field with `> ` prefix, send button
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
|
||||||
|
- Type a command and press send (or Enter on desktop)
|
||||||
|
- Up/down arrows navigate through command history
|
||||||
|
- Quick-command buttons populate and send common commands
|
||||||
|
- Bug report icon: Shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
|
||||||
|
- Help icon: Opens a scrollable reference of all known CLI commands. Tapping any command populates the input field immediately
|
||||||
|
- Clear icon: Wipes the command/response history
|
||||||
|
- Failed/timed-out commands are automatically retried once
|
||||||
|
|
||||||
|
### Available CLI Commands
|
||||||
|
|
||||||
|
**General**: `advert`, `reboot`, `clock`, `password`, `ver`, `clear stats`
|
||||||
|
|
||||||
|
**Settings**: `set name`, `set af`, `set tx`, `set repeat`, `set allow.read.only`, `set flood.max`, `set int.thresh`, `set agc.reset.interval`, `set multi.acks`, `set advert.interval`, `set flood.advert.interval`, `set guest.password`, `set lat`, `set lon`, `set radio`, `set rxdelay`, `set txdelay`, `set direct.txdelay`, `set bridge.*`, `set adc.multiplier`, `tempradio`, `setperm`
|
||||||
|
|
||||||
|
**Bridge**: `get bridge.type`
|
||||||
|
|
||||||
|
**Logging**: `log start`, `log stop`, `log erase`
|
||||||
|
|
||||||
|
**Neighbors**: `neighbors`, `neighbor.remove`
|
||||||
|
|
||||||
|
**Region Management**: `region`, `region load/get/put/remove/allowf/denyf/home/save`
|
||||||
|
|
||||||
|
**GPS**: `gps`, `gps on/off/sync/setloc/advert`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
A list of Cayenne LPP sensor channel cards:
|
||||||
|
|
||||||
|
- **Channel 1** (special): Battery voltage (shown as percentage or raw mV) and MCU temperature
|
||||||
|
- **Other channels**: Raw sensor values with appropriate labels
|
||||||
|
|
||||||
|
Shows "No data" until a response arrives from the repeater.
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
- Auto-queries on open
|
||||||
|
- Pull-to-refresh
|
||||||
|
- Temperature respects metric/imperial setting
|
||||||
|
- Battery readings are stored for the repeater's battery snapshot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Neighbors
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
A card titled "Repeater's Neighbors - N" listing each neighbor as:
|
||||||
|
- Repeater name (or hex key prefix if unknown)
|
||||||
|
- Time since last heard
|
||||||
|
- SNR quality icon with color coding and label
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
- Auto-queries up to 15 neighbors on open
|
||||||
|
- Matches public key prefixes against known contacts to show names
|
||||||
|
- Pull-to-refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repeater Settings
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
Five configuration cards:
|
||||||
|
|
||||||
|
**1. Basic Settings**
|
||||||
|
- Name field
|
||||||
|
- Admin password field
|
||||||
|
- Guest password field
|
||||||
|
|
||||||
|
**2. Radio Settings**
|
||||||
|
- Frequency (MHz)
|
||||||
|
- TX Power (dBm)
|
||||||
|
- Bandwidth dropdown (kHz)
|
||||||
|
- Spreading Factor (SF5–SF12)
|
||||||
|
- Coding Rate (4/5–4/8)
|
||||||
|
|
||||||
|
**3. Location Settings**
|
||||||
|
- Latitude and longitude fields
|
||||||
|
|
||||||
|
**4. Features**
|
||||||
|
- Packet forwarding toggle
|
||||||
|
- Guest access toggle
|
||||||
|
|
||||||
|
**5. Advertisement Settings**
|
||||||
|
- Local advert interval slider (60–240 minutes) with enable/disable toggle
|
||||||
|
- Flood advert interval slider (3–168 hours) with enable/disable toggle
|
||||||
|
|
||||||
|
**6. Danger Zone** (red-styled card)
|
||||||
|
- Reboot repeater
|
||||||
|
- Erase filesystem (serial-only warning)
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
- **Settings are NOT auto-fetched on open**. Only name and location are pre-filled from locally cached contact data. You must tap each section's refresh button to fetch live values from the repeater
|
||||||
|
- TX Power has its own separate refresh button, independent from the main Radio Settings refresh
|
||||||
|
- Save button appears when changes are detected
|
||||||
|
- Settings are sent sequentially with 200ms delays between commands (fire-and-forget, no per-command acknowledgment wait)
|
||||||
|
- Validation prevents invalid values (e.g., frequency range, LoRa parameter compatibility)
|
||||||
|
- Advertisement interval sliders reset to defaults when re-enabled (local: 60 min, flood: 3 hours)
|
||||||
|
- **Erase Filesystem** does NOT send any command over the air — tapping it only shows a snackbar explaining the operation requires physical serial access. It is effectively non-functional when connected wirelessly
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Scanner & Connection
|
||||||
|
|
||||||
|
## BLE Scanner (Home Screen)
|
||||||
|
|
||||||
|
The BLE Scanner is the app's home screen, displayed immediately on launch.
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
|
||||||
|
- Opens automatically when the app starts
|
||||||
|
- Returns here when disconnecting from any device
|
||||||
|
- Accessible by navigating back from a connected session
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
**App Bar**: Centered title "Scanner".
|
||||||
|
|
||||||
|
**Bluetooth-Off Warning Banner** (conditional): Appears when the Bluetooth adapter is off, showing a `bluetooth_disabled` icon, a warning message, and on Android, an "Enable Bluetooth" button.
|
||||||
|
|
||||||
|
**Status Bar**: A full-width colored strip reflecting the current connection state:
|
||||||
|
|
||||||
|
| State | Text | Color |
|
||||||
|
|---|---|---|
|
||||||
|
| Disconnected | "Not connected" | Grey |
|
||||||
|
| Scanning | "Scanning..." | Blue |
|
||||||
|
| Connecting | "Connecting..." | Orange |
|
||||||
|
| Connected | "Connected to \<device name\>" | Green |
|
||||||
|
| Disconnecting | "Disconnecting..." | Orange |
|
||||||
|
|
||||||
|
**Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets.
|
||||||
|
|
||||||
|
**Bottom FAB Row**: Up to three floating action buttons:
|
||||||
|
- **USB** button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
|
||||||
|
- **TCP/IP** button - Opens TCP connection screen (all non-web platforms)
|
||||||
|
- **BLE Scan** button - Toggles BLE scanning on/off; shows a spinner when scanning. **Disabled** (greyed out, not tappable) when Bluetooth is off
|
||||||
|
|
||||||
|
### Device Tile
|
||||||
|
|
||||||
|
Each discovered device is displayed as a list tile showing:
|
||||||
|
- **Signal strength icon** (color-coded by RSSI):
|
||||||
|
- Green: >= -60 dBm (excellent)
|
||||||
|
- Light green: -60 to -70 dBm (good)
|
||||||
|
- Amber: -70 to -80 dBm (fair)
|
||||||
|
- Orange: -80 to -90 dBm (weak)
|
||||||
|
- Red: < -90 dBm (poor)
|
||||||
|
- **RSSI value** in dBm (e.g., "-72 dBm")
|
||||||
|
- **Device name** (falls back to "Unknown Device")
|
||||||
|
- **Device ID** (BLE MAC address on Android; a system-assigned UUID on iOS/macOS)
|
||||||
|
- **Connect button** (the entire tile row is also tappable — both trigger connection)
|
||||||
|
|
||||||
|
Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon shape and are only differentiated by color (orange vs. red).
|
||||||
|
|
||||||
|
### How Scanning Works
|
||||||
|
|
||||||
|
- Filters for devices with names starting with `MeshCore-` or `Whisper-`
|
||||||
|
- Uses low-latency scan mode on Android
|
||||||
|
- Scans for 10 seconds then auto-stops
|
||||||
|
- On iOS/macOS, waits for BLE adapter initialization before starting
|
||||||
|
- If Bluetooth is turned off during a scan, scanning stops immediately
|
||||||
|
|
||||||
|
### Connecting to a Device
|
||||||
|
|
||||||
|
Tap a device tile or its Connect button:
|
||||||
|
1. The connector stops scanning and transitions to "connecting"
|
||||||
|
2. Connects to the device with a 15-second timeout
|
||||||
|
3. Requests MTU 185 bytes for optimal throughput
|
||||||
|
4. Discovers BLE services and locates the Nordic UART Service
|
||||||
|
5. Subscribes to TX notifications for receiving data
|
||||||
|
6. On success, automatically navigates to the Contacts screen
|
||||||
|
7. On failure, shows a red error snackbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## USB Connection
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
|
||||||
|
From the Scanner screen, tap the **USB** FAB button.
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
- A colored status bar at the top (same color scheme as BLE scanner)
|
||||||
|
- A list of detected USB serial ports, each showing:
|
||||||
|
- Friendly display name
|
||||||
|
- Raw port name (subtitle, only shown when it differs from the display name)
|
||||||
|
- "Connect" button
|
||||||
|
- FABs at the bottom to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
|
||||||
|
- On desktop (Windows, Linux, macOS): ports are polled every 2 seconds for hot-plug detection (polling pauses while connecting/connected)
|
||||||
|
- On mobile: tap the "Scan" FAB to manually refresh
|
||||||
|
- Tap a port or its Connect button to connect
|
||||||
|
- On successful connection, navigates to Contacts screen
|
||||||
|
- On connection failure, the port list automatically refreshes
|
||||||
|
- Platform-specific error messages for common USB failures (permission denied, device missing, device detached, device busy, driver missing, port invalid, timeout, and more)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TCP Connection
|
||||||
|
|
||||||
|
### How to Access
|
||||||
|
|
||||||
|
From the Scanner screen, tap the **TCP/IP** FAB button.
|
||||||
|
|
||||||
|
### What the User Sees
|
||||||
|
|
||||||
|
- A colored status bar at the top
|
||||||
|
- **Host address** text field
|
||||||
|
- **Port number** text field
|
||||||
|
- **Connect** button
|
||||||
|
- FABs at the bottom to switch to USB or BLE
|
||||||
|
|
||||||
|
### Key Interactions
|
||||||
|
|
||||||
|
- Last-used host and port are pre-populated from saved settings
|
||||||
|
- Tap Connect to validate inputs and connect
|
||||||
|
- Host must not be empty
|
||||||
|
- Port must be a number between 1 and 65535
|
||||||
|
- Validation errors are shown as red snackbars
|
||||||
|
- The Connect button shows a spinner and "Connecting..." label while in progress
|
||||||
|
- The status bar shows the specific host:port being connected to (e.g., "Connecting to 192.168.1.1:5000")
|
||||||
|
- On success, navigates to Contacts screen and saves the host/port to settings
|
||||||
|
- On connection, the status bar shows the active TCP endpoint (e.g., "Connected to 192.168.1.1:5000")
|
||||||
|
- Error messages for timeout, unsupported platform, and connection failures
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Settings
|
||||||
|
|
||||||
|
## How to Access
|
||||||
|
|
||||||
|
- From the Device Screen: tap the tune/sliders icon in the app bar
|
||||||
|
- From Contacts or Channels: overflow menu (three-dot) → Settings
|
||||||
|
|
||||||
|
Settings are only accessible while a device is connected.
|
||||||
|
|
||||||
|
## Settings Screen Layout
|
||||||
|
|
||||||
|
The settings screen is a scrollable list of cards:
|
||||||
|
|
||||||
|
1. [Device Info](#device-info)
|
||||||
|
2. [App Settings](#app-settings) (link to sub-screen)
|
||||||
|
3. [Node Settings](#node-settings)
|
||||||
|
4. [Actions](#actions)
|
||||||
|
5. [Debug](#debug)
|
||||||
|
6. [Export](#export)
|
||||||
|
7. [About](#about)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Device Info
|
||||||
|
|
||||||
|
A collapsible card showing read-only device information. **Collapsed by default** — tap the header to expand with an animated chevron indicator:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| Name | Connected device's display name |
|
||||||
|
| ID | Device identifier |
|
||||||
|
| Status | Connected / Disconnected |
|
||||||
|
| Battery | Percentage or voltage (tap to toggle) |
|
||||||
|
| Node Name | The node's mesh identity name |
|
||||||
|
| Public Key | First 16 hex characters + "..." |
|
||||||
|
| Contacts Count | Number of known contacts |
|
||||||
|
| Channel Count | Number of configured channels |
|
||||||
|
|
||||||
|
Battery shows an alert icon and orange text when at 15% or below. The toggle only works when millivolt data is available from the firmware.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Settings
|
||||||
|
|
||||||
|
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
|
||||||
|
|
||||||
|
### Appearance
|
||||||
|
- **Theme**: System / Light / Dark
|
||||||
|
- **Language**: System default or one of 15 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian)
|
||||||
|
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- **Master enable/disable**: Requests OS permission when enabling
|
||||||
|
- **Message notifications**: New direct message alerts
|
||||||
|
- **Channel message notifications**: New channel message alerts
|
||||||
|
- **Advertisement notifications**: New node discovery alerts
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
|
||||||
|
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
|
||||||
|
- Max Route Weight (1–10, default 5, integer steps)
|
||||||
|
- Initial Route Weight (0.5–5.0, default 3.0)
|
||||||
|
- Success Increment (0.1–2.0, default 0.5, 0.1 steps)
|
||||||
|
- Failure Decrement (0.1–2.0, default 0.2, 0.1 steps)
|
||||||
|
- Max Message Retries (2–10, default 5)
|
||||||
|
|
||||||
|
### Battery
|
||||||
|
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
|
||||||
|
|
||||||
|
### Map Display
|
||||||
|
- **Show Repeaters**: Toggle repeater markers on map
|
||||||
|
- **Show Chat Nodes**: Toggle chat node markers
|
||||||
|
- **Show Other Nodes**: Toggle room/sensor markers
|
||||||
|
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
|
||||||
|
- **Units**: Metric / Imperial
|
||||||
|
- **Offline Map Cache**: Navigate to tile download screen
|
||||||
|
|
||||||
|
### Debug
|
||||||
|
- **App Debug Logging**: Enable the in-app debug log
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node Settings
|
||||||
|
|
||||||
|
These settings are sent directly to the connected device firmware.
|
||||||
|
|
||||||
|
### Node Name
|
||||||
|
- Opens a dialog with a text field (max 31 characters)
|
||||||
|
- Sends the new name to the device
|
||||||
|
- Confirmed via snackbar
|
||||||
|
|
||||||
|
### Radio Settings
|
||||||
|
Opens a dialog pre-populated with the device's current radio settings. Contains:
|
||||||
|
- **Preset dropdown**: 19 regional presets — selecting a preset immediately fills all fields below. Full list: Australia, Australia (Narrow), Australia SA/WA/QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, Switzerland, USA Arizona, USA/Canada, Vietnam, Off-Grid 433, Off-Grid 869, Off-Grid 918
|
||||||
|
- **Frequency** (MHz): Free text, validated 300–2500 MHz
|
||||||
|
- **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz)
|
||||||
|
- **Spreading Factor**: SF5–SF12
|
||||||
|
- **Coding Rate**: 4/5, 4/6, 4/7, 4/8
|
||||||
|
- **TX Power** (dBm): Validated 0 to device max (typically 22 dBm)
|
||||||
|
- **Client Repeat** toggle: Only shown on firmware v9+; requires frequency to be exactly 433.000, 869.000, or 918.000 MHz (the Off-Grid presets). Save is blocked with a warning if enabled on other frequencies
|
||||||
|
|
||||||
|
### Location
|
||||||
|
Opens a dialog pre-populated with the device's current coordinates (if known):
|
||||||
|
- Latitude and longitude fields (decimal, 6 decimal places). If only one field is provided, the other uses the device's current value
|
||||||
|
- If GPS-capable hardware (detected via `gps` custom variable):
|
||||||
|
- GPS Update Interval (seconds, 60–86399, default 900 = 15 minutes). Validated and sent separately before lat/lon
|
||||||
|
- Enable GPS toggle (takes effect immediately, not deferred to Save)
|
||||||
|
- Validation: lat ±90, lon ±180
|
||||||
|
|
||||||
|
### Contact Settings
|
||||||
|
Five toggles controlling which node types are auto-added when heard:
|
||||||
|
- Auto-add Chat Users
|
||||||
|
- Auto-add Repeaters
|
||||||
|
- Auto-add Room Servers
|
||||||
|
- Auto-add Sensors
|
||||||
|
- Overwrite Oldest (when contact list is full)
|
||||||
|
|
||||||
|
### Privacy Mode
|
||||||
|
Opens a confirmation dialog with three buttons: Cancel, Enable, and Disable. Both states can be set from the same dialog regardless of current state. A snackbar confirms which state was applied. When on, the node stops broadcasting its location in advertisements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
One-tap device operations:
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|---|---|
|
||||||
|
| Send Advertisement | Floods the mesh with your node's advertisement |
|
||||||
|
| Sync Time | Sends current Unix timestamp to the device |
|
||||||
|
| Refresh Contacts | Re-requests the full contact list |
|
||||||
|
| Reboot Device | Confirmation dialog → reboots the device (shown in orange) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug
|
||||||
|
|
||||||
|
Two log viewers accessible via list tiles:
|
||||||
|
|
||||||
|
### BLE Debug Log
|
||||||
|
Two views (togglable via segmented button):
|
||||||
|
- **Frames view**: Direction icon, description, hex preview, timestamp per frame. Long-press to copy hex.
|
||||||
|
- **Raw Log RX view**: Decoded LoRa packets with route type, payload type, path, and summary.
|
||||||
|
- Copy-all and Clear buttons in the app bar.
|
||||||
|
|
||||||
|
### App Debug Log
|
||||||
|
Structured log entries (Info / Warning / Error), with tag, message, and timestamp.
|
||||||
|
- Must be enabled first in App Settings → Debug
|
||||||
|
- Copy-all and Clear buttons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
Three GPX export options (not available on web):
|
||||||
|
|
||||||
|
| Option | Exports |
|
||||||
|
|---|---|
|
||||||
|
| Export Repeaters | Repeaters and Rooms with GPS coordinates |
|
||||||
|
| Export Contacts | Chat contacts with GPS coordinates |
|
||||||
|
| Export All | All contacts with GPS coordinates |
|
||||||
|
|
||||||
|
Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
Shows the standard Flutter about dialog with app name, version, and legal notice.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -64,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);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -37,16 +39,6 @@ class BufferReader {
|
|||||||
|
|
||||||
Uint8List readRemainingBytes() => readBytes(remaining);
|
Uint8List readRemainingBytes() => readBytes(remaining);
|
||||||
|
|
||||||
String readString() {
|
|
||||||
_lastPointer = _pointer;
|
|
||||||
final value = readRemainingBytes();
|
|
||||||
try {
|
|
||||||
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
|
||||||
} catch (e) {
|
|
||||||
return String.fromCharCodes(value); // Latin-1 fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String readCStringGreedy(int maxLength) {
|
String readCStringGreedy(int maxLength) {
|
||||||
_lastPointer = _pointer;
|
_lastPointer = _pointer;
|
||||||
final value = <int>[];
|
final value = <int>[];
|
||||||
@@ -62,11 +54,12 @@ class BufferReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String readCString(int maxLength) {
|
String readCString({int maxLength = -1}) {
|
||||||
final backupPointer = _pointer;
|
final backupPointer = _pointer;
|
||||||
final value = <int>[];
|
final value = <int>[];
|
||||||
int counter = 0;
|
int counter = 0;
|
||||||
while (counter < maxLength) {
|
final maxLen = maxLength >= 0 ? maxLength : remaining;
|
||||||
|
while (counter < maxLen) {
|
||||||
final byte = readByte();
|
final byte = readByte();
|
||||||
if (byte == 0) break;
|
if (byte == 0) break;
|
||||||
value.add(byte);
|
value.add(byte);
|
||||||
@@ -210,7 +203,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;
|
||||||
@@ -220,6 +213,7 @@ const int cmdGetAutoAddConfig = 59;
|
|||||||
// Text message types
|
// Text message types
|
||||||
const int txtTypePlain = 0;
|
const int txtTypePlain = 0;
|
||||||
const int txtTypeCliData = 1;
|
const int txtTypeCliData = 1;
|
||||||
|
const int txtTypeSigned = 2;
|
||||||
|
|
||||||
// Repeater request types (for server requests)
|
// Repeater request types (for server requests)
|
||||||
const int reqTypeGetStatus = 0x01;
|
const int reqTypeGetStatus = 0x01;
|
||||||
@@ -272,6 +266,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)
|
||||||
@@ -310,6 +308,7 @@ const int autoAddSensorFlag =
|
|||||||
|
|
||||||
// Sizes
|
// Sizes
|
||||||
const int pubKeySize = 32;
|
const int pubKeySize = 32;
|
||||||
|
const int signatureSize = 64;
|
||||||
const int maxPathSize = 64;
|
const int maxPathSize = 64;
|
||||||
const int pathHashSize = 1;
|
const int pathHashSize = 1;
|
||||||
const int maxNameSize = 32;
|
const int maxNameSize = 32;
|
||||||
@@ -352,6 +351,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;
|
||||||
@@ -370,52 +372,44 @@ const int msgTextOffset = 38;
|
|||||||
class ParsedContactText {
|
class ParsedContactText {
|
||||||
final Uint8List senderPrefix;
|
final Uint8List senderPrefix;
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
const ParsedContactText({required this.senderPrefix, required this.text});
|
const ParsedContactText({required this.senderPrefix, required this.text});
|
||||||
}
|
}
|
||||||
|
|
||||||
ParsedContactText? parseContactMessageText(Uint8List frame) {
|
ParsedContactText? parseContactMessageText(Uint8List frame) {
|
||||||
if (frame.isEmpty) return null;
|
if (frame.isEmpty) return null;
|
||||||
final code = frame[0];
|
|
||||||
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
final message = BufferReader(frame);
|
||||||
|
try {
|
||||||
|
final code = message.readByte();
|
||||||
|
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Companion radio layout:
|
||||||
|
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
|
||||||
|
if (code == respCodeContactMsgRecvV3) {
|
||||||
|
// Skip SNR and reserved bytes in v3 layout
|
||||||
|
message.skipBytes(3);
|
||||||
|
}
|
||||||
|
final senderPrefix = message.readBytes(6); // public key
|
||||||
|
message.skipBytes(1); // path length
|
||||||
|
final textType = message.readByte();
|
||||||
|
message.skipBytes(4); // timestamp (4 bytes)
|
||||||
|
|
||||||
|
final shiftedType = textType >> 2;
|
||||||
|
final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned;
|
||||||
|
if (isSigned) {
|
||||||
|
// Signed messages have a 4-byte signature after the timestamp, before the text
|
||||||
|
message.skipBytes(4);
|
||||||
|
}
|
||||||
|
final text = message.readCString();
|
||||||
|
if (text.isEmpty) return null;
|
||||||
|
|
||||||
|
return ParsedContactText(senderPrefix: senderPrefix, text: text);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error parsing contact message text: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Companion radio layout:
|
|
||||||
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
|
|
||||||
final isV3 = code == respCodeContactMsgRecvV3;
|
|
||||||
final prefixOffset = isV3 ? 4 : 1;
|
|
||||||
const prefixLen = 6;
|
|
||||||
final txtTypeOffset = prefixOffset + prefixLen + 1;
|
|
||||||
final timestampOffset = txtTypeOffset + 1;
|
|
||||||
final baseTextOffset = timestampOffset + 4;
|
|
||||||
if (frame.length <= baseTextOffset) return null;
|
|
||||||
|
|
||||||
final flags = frame[txtTypeOffset];
|
|
||||||
final shiftedType = flags >> 2;
|
|
||||||
final rawType = flags;
|
|
||||||
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
|
|
||||||
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
|
|
||||||
if (!isPlain && !isCli) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var text = readCString(
|
|
||||||
frame,
|
|
||||||
baseTextOffset,
|
|
||||||
frame.length - baseTextOffset,
|
|
||||||
).trim();
|
|
||||||
if (text.isEmpty && frame.length > baseTextOffset + 4) {
|
|
||||||
text = readCString(
|
|
||||||
frame,
|
|
||||||
baseTextOffset + 4,
|
|
||||||
frame.length - (baseTextOffset + 4),
|
|
||||||
).trim();
|
|
||||||
}
|
|
||||||
if (text.isEmpty) return null;
|
|
||||||
|
|
||||||
final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
|
|
||||||
return ParsedContactText(senderPrefix: senderPrefix, text: text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to read uint32 little-endian
|
// Helper to read uint32 little-endian
|
||||||
@@ -438,18 +432,9 @@ int readInt32LE(Uint8List data, int offset) {
|
|||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to read null-terminated UTF-8 string
|
// Helper to convert uint32 to hex string
|
||||||
String readCString(Uint8List data, int offset, int maxLen) {
|
String ackHashToHex(int ackHash) {
|
||||||
int end = offset;
|
return ackHash.toRadixString(16).padLeft(8, '0');
|
||||||
while (end < offset + maxLen && end < data.length && data[end] != 0) {
|
|
||||||
end++;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return utf8.decode(data.sublist(offset, end), allowMalformed: true);
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to Latin-1 if UTF-8 decoding fails
|
|
||||||
return String.fromCharCodes(data.sublist(offset, end));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to convert public key to hex string
|
// Helper to convert public key to hex string
|
||||||
@@ -509,7 +494,7 @@ Uint8List buildSendTextMsgFrame(
|
|||||||
final writer = BufferWriter();
|
final writer = BufferWriter();
|
||||||
writer.writeByte(cmdSendTxtMsg);
|
writer.writeByte(cmdSendTxtMsg);
|
||||||
writer.writeByte(txtTypePlain);
|
writer.writeByte(txtTypePlain);
|
||||||
writer.writeByte(attempt.clamp(0, 3));
|
writer.writeByte(attempt.clamp(0, 255));
|
||||||
writer.writeUInt32LE(timestamp);
|
writer.writeUInt32LE(timestamp);
|
||||||
writer.writeBytes(recipientPubKey.sublist(0, 6));
|
writer.writeBytes(recipientPubKey.sublist(0, 6));
|
||||||
writer.writeString(text);
|
writer.writeString(text);
|
||||||
@@ -838,7 +823,7 @@ Uint8List buildSendCliCommandFrame(
|
|||||||
final writer = BufferWriter();
|
final writer = BufferWriter();
|
||||||
writer.writeByte(cmdSendTxtMsg);
|
writer.writeByte(cmdSendTxtMsg);
|
||||||
writer.writeByte(txtTypeCliData);
|
writer.writeByte(txtTypeCliData);
|
||||||
writer.writeByte(attempt.clamp(0, 3));
|
writer.writeByte(attempt.clamp(0, 255));
|
||||||
writer.writeUInt32LE(timestamp);
|
writer.writeUInt32LE(timestamp);
|
||||||
writer.writeBytes(repeaterPubKey.sublist(0, 6));
|
writer.writeBytes(repeaterPubKey.sublist(0, 6));
|
||||||
writer.writeString(command);
|
writer.writeString(command);
|
||||||
@@ -937,3 +922,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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,47 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
|
|
||||||
class LinkHandler {
|
class LinkHandler {
|
||||||
|
/// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile.
|
||||||
|
static Widget buildLinkifyText({
|
||||||
|
required BuildContext context,
|
||||||
|
required String text,
|
||||||
|
required TextStyle style,
|
||||||
|
TextStyle? linkStyle,
|
||||||
|
}) {
|
||||||
|
final effectiveLinkStyle =
|
||||||
|
linkStyle ??
|
||||||
|
style.copyWith(
|
||||||
|
color: Colors.green,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
);
|
||||||
|
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
|
||||||
|
const linkifiers = [UrlLinkifier()];
|
||||||
|
void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
|
||||||
|
|
||||||
|
if (PlatformInfo.isDesktop) {
|
||||||
|
return SelectableLinkify(
|
||||||
|
text: text,
|
||||||
|
style: style,
|
||||||
|
linkStyle: effectiveLinkStyle,
|
||||||
|
options: options,
|
||||||
|
linkifiers: linkifiers,
|
||||||
|
onOpen: onOpen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Linkify(
|
||||||
|
text: text,
|
||||||
|
style: style,
|
||||||
|
linkStyle: effectiveLinkStyle,
|
||||||
|
options: options,
|
||||||
|
linkifiers: linkifiers,
|
||||||
|
onOpen: onOpen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> handleLinkTap(BuildContext context, String url) async {
|
static Future<void> handleLinkTap(BuildContext context, String url) async {
|
||||||
// Show confirmation dialog
|
// Show confirmation dialog
|
||||||
final shouldOpen = await showDialog<bool>(
|
final shouldOpen = await showDialog<bool>(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import '../models/contact.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
|
class PathHelper {
|
||||||
|
static String formatPathHex(List<int> pathBytes) {
|
||||||
|
return pathBytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String resolvePathNames(
|
||||||
|
List<int> pathBytes,
|
||||||
|
List<Contact> allContacts,
|
||||||
|
) {
|
||||||
|
return pathBytes
|
||||||
|
.map((b) {
|
||||||
|
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||||
|
final matches = allContacts
|
||||||
|
.where(
|
||||||
|
(c) =>
|
||||||
|
c.publicKey.first == b &&
|
||||||
|
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (matches.isEmpty) return hex;
|
||||||
|
if (matches.length == 1) return matches.first.name;
|
||||||
|
return matches.map((c) => c.name).join(' | ');
|
||||||
|
})
|
||||||
|
.join(' \u2192 ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,50 @@ class ReactionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ReactionHelper {
|
class ReactionHelper {
|
||||||
|
/// Apply a reaction to a list of messages by matching the reaction hash.
|
||||||
|
///
|
||||||
|
/// [messages] - the message list to search
|
||||||
|
/// [reactionInfo] - the parsed reaction
|
||||||
|
/// [getTimestampSecs] - extract timestamp seconds from a message
|
||||||
|
/// [getSenderName] - extract sender name for hash (null for 1:1 implicit)
|
||||||
|
/// [getMessageText] - extract message text
|
||||||
|
/// [getReactions] - extract current reactions map
|
||||||
|
/// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions)
|
||||||
|
/// [updateMessage] - callback to update the message at index with new reactions
|
||||||
|
///
|
||||||
|
/// Returns whether a match was found.
|
||||||
|
static bool applyReaction<T>({
|
||||||
|
required List<T> messages,
|
||||||
|
required ReactionInfo reactionInfo,
|
||||||
|
required int Function(T) getTimestampSecs,
|
||||||
|
required String? Function(T) getSenderName,
|
||||||
|
required String Function(T) getMessageText,
|
||||||
|
required Map<String, int> Function(T) getReactions,
|
||||||
|
required bool Function(T) shouldSkip,
|
||||||
|
required void Function(int index, Map<String, int> newReactions)
|
||||||
|
updateMessage,
|
||||||
|
}) {
|
||||||
|
final targetHash = reactionInfo.targetHash;
|
||||||
|
for (int i = messages.length - 1; i >= 0; i--) {
|
||||||
|
final msg = messages[i];
|
||||||
|
if (shouldSkip(msg)) continue;
|
||||||
|
|
||||||
|
final msgHash = computeReactionHash(
|
||||||
|
getTimestampSecs(msg),
|
||||||
|
getSenderName(msg),
|
||||||
|
getMessageText(msg),
|
||||||
|
);
|
||||||
|
if (msgHash == targetHash) {
|
||||||
|
final currentReactions = Map<String, int>.from(getReactions(msg));
|
||||||
|
currentReactions[reactionInfo.emoji] =
|
||||||
|
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
||||||
|
updateMessage(i, currentReactions);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static List<String>? _cachedEmojis;
|
static List<String>? _cachedEmojis;
|
||||||
|
|
||||||
/// Combined list of all reaction emojis in fixed order.
|
/// Combined list of all reaction emojis in fixed order.
|
||||||
|
|||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
||||||
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||||
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||||
"map_showDiscoveryContacts": "Покажи контакти за откриване"
|
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||||
}
|
"map_setAsMyLocation": "Задайте като моя местоположение",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_initialRouteWeight": "Първоначална тежест на маршрута",
|
||||||
|
"appSettings_maxRouteWeight": "Максимално допустимо тегло на маршрута",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Начално тегло за новооткрити маршрути",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Максималното тегло, което един маршрут може да събере от успешни доставки.",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Увеличение на теглото за успех",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Тегло, добавено към път след успешно доставяне.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Намаляване на теглото, свързано с неуспех",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Тегло, което е било премахнато от пътя след неуспешен опит за доставка.",
|
||||||
|
"appSettings_maxMessageRetries": "Максимален брой опити за изпращане на съобщение",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_multiAck": "Мулти-потвърди: {value}",
|
||||||
|
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен"
|
||||||
|
}
|
||||||
+55
-2
@@ -1916,5 +1916,58 @@
|
|||||||
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
|
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
|
||||||
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
||||||
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
||||||
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen"
|
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
|
||||||
}
|
"map_setAsMyLocation": "Als meine aktuelle Position festlegen",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Ausgangsgewicht für neu entdeckte Pfade",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.",
|
||||||
|
"appSettings_maxRouteWeight": "Maximale Gesamtstreckenlänge",
|
||||||
|
"appSettings_initialRouteWeight": "Anfangs-Streckengewicht",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Erhöhung des Erfolgsgewichts",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Reduzierung des Gewichts bei Fehlern",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde",
|
||||||
|
"appSettings_maxMessageRetries": "Maximale Anzahl an Wiederholungsversuchen",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Telemetriemodus aktualisiert",
|
||||||
|
"settings_multiAck": "Mehrfach-Bestätigungen: {value}"
|
||||||
|
}
|
||||||
+50
-1
@@ -166,6 +166,26 @@
|
|||||||
"settings_privacyModeToggle": "Toggle privacy mode to hide your name and location in advertisements.",
|
"settings_privacyModeToggle": "Toggle privacy mode to hide your name and location in advertisements.",
|
||||||
"settings_privacyModeEnabled": "Privacy mode enabled",
|
"settings_privacyModeEnabled": "Privacy mode enabled",
|
||||||
"settings_privacyModeDisabled": "Privacy mode disabled",
|
"settings_privacyModeDisabled": "Privacy mode disabled",
|
||||||
|
"settings_privacy": "Privacy Settings",
|
||||||
|
"settings_privacySubtitle": "Control what information is shared.",
|
||||||
|
"settings_privacySettingsDescription": "Choose what information your device shares with others.",
|
||||||
|
"settings_denyAll": "Deny all",
|
||||||
|
"settings_allowByContact": "Allow by contact flags",
|
||||||
|
"settings_allowAll": "Allow all",
|
||||||
|
"settings_telemetryBaseMode": "Telemetry Base Mode",
|
||||||
|
"settings_telemetryLocationMode": "Telemetry Location Mode",
|
||||||
|
"settings_telemetryEnvironmentMode": "Telemetry Environment Mode",
|
||||||
|
"settings_advertLocation": "Advert Location",
|
||||||
|
"settings_advertLocationSubtitle": "Include location in advert.",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}",
|
||||||
|
"@settings_multiAck": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_telemetryModeUpdated": "Telemetry mode updated",
|
||||||
"settings_actions": "Actions",
|
"settings_actions": "Actions",
|
||||||
"settings_sendAdvertisement": "Send Advertisement",
|
"settings_sendAdvertisement": "Send Advertisement",
|
||||||
"settings_sendAdvertisementSubtitle": "Broadcast presence now",
|
"settings_sendAdvertisementSubtitle": "Broadcast presence now",
|
||||||
@@ -269,6 +289,23 @@
|
|||||||
"appSettings_autoRouteRotationSubtitle": "Cycle between best paths and flood mode",
|
"appSettings_autoRouteRotationSubtitle": "Cycle between best paths and flood mode",
|
||||||
"appSettings_autoRouteRotationEnabled": "Auto route rotation enabled",
|
"appSettings_autoRouteRotationEnabled": "Auto route rotation enabled",
|
||||||
"appSettings_autoRouteRotationDisabled": "Auto route rotation disabled",
|
"appSettings_autoRouteRotationDisabled": "Auto route rotation disabled",
|
||||||
|
"appSettings_maxRouteWeight": "Max Route Weight",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Maximum weight a path can accumulate from successful deliveries",
|
||||||
|
"appSettings_initialRouteWeight": "Initial Route Weight",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Starting weight for newly discovered paths",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Success Weight Increment",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Weight added to a path after successful delivery",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Failure Weight Decrement",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Weight removed from a path after failed delivery",
|
||||||
|
"appSettings_maxMessageRetries": "Max Message Retries",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Number of retry attempts before marking a message as failed",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": { "type": "String" },
|
||||||
|
"max": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"appSettings_battery": "Battery",
|
"appSettings_battery": "Battery",
|
||||||
"appSettings_batteryChemistry": "Battery Chemistry",
|
"appSettings_batteryChemistry": "Battery Chemistry",
|
||||||
"appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})",
|
"appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})",
|
||||||
@@ -455,6 +492,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",
|
||||||
@@ -808,6 +856,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",
|
||||||
@@ -1927,4 +1976,4 @@
|
|||||||
"discoveredContacts_deleteContact": "Delete Discovered Contact",
|
"discoveredContacts_deleteContact": "Delete Discovered Contact",
|
||||||
"discoveredContacts_deleteContactAll": "Delete All Discovered Contacts",
|
"discoveredContacts_deleteContactAll": "Delete All Discovered Contacts",
|
||||||
"discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?"
|
"discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?"
|
||||||
}
|
}
|
||||||
+55
-2
@@ -1916,5 +1916,58 @@
|
|||||||
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
|
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
|
||||||
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
||||||
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento"
|
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
|
||||||
}
|
"map_setAsMyLocation": "Establecer mi ubicación",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_initialRouteWeight": "Peso inicial de la ruta",
|
||||||
|
"appSettings_maxRouteWeight": "Peso máximo permitido para la ruta",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Peso inicial para rutas recién descubiertas",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Peso máximo que una ruta puede acumular gracias a entregas exitosas.",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Incremento de peso para el éxito",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso añadido a una ruta después de una entrega exitosa.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Reducción del peso asociado al fallo",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Peso retirado de un camino después de un intento de entrega fallido.",
|
||||||
|
"appSettings_maxMessageRetries": "Número máximo de reintentos de envío de mensajes",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Número de intentos de reintento antes de marcar un mensaje como fallido.",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Modo de telemetría actualizado",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
|
}
|
||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
|
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
|
||||||
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
||||||
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
||||||
"map_showDiscoveryContacts": "Afficher les contacts de découverte"
|
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
|
||||||
}
|
"map_setAsMyLocation": "Définir comme ma localisation",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Poids maximal qu'un itinéraire peut accumuler grâce à des livraisons réussies.",
|
||||||
|
"appSettings_initialRouteWeight": "Poids initial de l'itinéraire",
|
||||||
|
"appSettings_maxRouteWeight": "Poids maximal autorisé pour le trajet",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Poids de départ pour les nouveaux chemins découverts",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Augmentation du poids de réussite",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Poids ajouté à un itinéraire après une livraison réussie.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Réduction du poids de pénalité",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Poids retiré d'un itinéraire après une tentative de livraison infructueuse.",
|
||||||
|
"appSettings_maxMessageRetries": "Nombre maximal de tentatives de récupération de messages",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Nombre de tentatives de relance avant de marquer un message comme ayant échoué.",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_multiAck": "Multi-ACKs : {value}",
|
||||||
|
"settings_telemetryModeUpdated": "Le mode télémétrie a été mis à jour"
|
||||||
|
}
|
||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
|
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
|
||||||
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
||||||
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Mostra Contatti di Discovery"
|
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
|
||||||
}
|
"map_setAsMyLocation": "Imposta come la mia posizione",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_initialRouteWeight": "Peso iniziale del percorso",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Peso di partenza per nuovi percorsi",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Il peso massimo che un percorso può accumulare grazie a consegne di successo.",
|
||||||
|
"appSettings_maxRouteWeight": "Massimo peso consentito per il percorso",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Aumento del peso del successo",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso aggiunto a un percorso dopo una consegna riuscita.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Riduzione del peso associato al fallimento",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Peso rimosso da un percorso dopo un tentativo di consegna fallito.",
|
||||||
|
"appSettings_maxMessageRetries": "Numero massimo di tentativi di invio del messaggio",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Numero di tentativi di riprova prima di considerare un messaggio come fallito.",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Modalità telemetria aggiornata",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
|
}
|
||||||
@@ -826,6 +826,84 @@ abstract class AppLocalizations {
|
|||||||
/// **'Privacy mode disabled'**
|
/// **'Privacy mode disabled'**
|
||||||
String get settings_privacyModeDisabled;
|
String get settings_privacyModeDisabled;
|
||||||
|
|
||||||
|
/// No description provided for @settings_privacy.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Privacy Settings'**
|
||||||
|
String get settings_privacy;
|
||||||
|
|
||||||
|
/// No description provided for @settings_privacySubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Control what information is shared.'**
|
||||||
|
String get settings_privacySubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @settings_privacySettingsDescription.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose what information your device shares with others.'**
|
||||||
|
String get settings_privacySettingsDescription;
|
||||||
|
|
||||||
|
/// No description provided for @settings_denyAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Deny all'**
|
||||||
|
String get settings_denyAll;
|
||||||
|
|
||||||
|
/// No description provided for @settings_allowByContact.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow by contact flags'**
|
||||||
|
String get settings_allowByContact;
|
||||||
|
|
||||||
|
/// No description provided for @settings_allowAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow all'**
|
||||||
|
String get settings_allowAll;
|
||||||
|
|
||||||
|
/// No description provided for @settings_telemetryBaseMode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Base Mode'**
|
||||||
|
String get settings_telemetryBaseMode;
|
||||||
|
|
||||||
|
/// No description provided for @settings_telemetryLocationMode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Location Mode'**
|
||||||
|
String get settings_telemetryLocationMode;
|
||||||
|
|
||||||
|
/// No description provided for @settings_telemetryEnvironmentMode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry Environment Mode'**
|
||||||
|
String get settings_telemetryEnvironmentMode;
|
||||||
|
|
||||||
|
/// No description provided for @settings_advertLocation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Advert Location'**
|
||||||
|
String get settings_advertLocation;
|
||||||
|
|
||||||
|
/// No description provided for @settings_advertLocationSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Include location in advert.'**
|
||||||
|
String get settings_advertLocationSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @settings_multiAck.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Multi-ACKs: {value}'**
|
||||||
|
String settings_multiAck(String value);
|
||||||
|
|
||||||
|
/// No description provided for @settings_telemetryModeUpdated.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telemetry mode updated'**
|
||||||
|
String get settings_telemetryModeUpdated;
|
||||||
|
|
||||||
/// No description provided for @settings_actions.
|
/// No description provided for @settings_actions.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1360,6 +1438,72 @@ abstract class AppLocalizations {
|
|||||||
/// **'Auto route rotation disabled'**
|
/// **'Auto route rotation disabled'**
|
||||||
String get appSettings_autoRouteRotationDisabled;
|
String get appSettings_autoRouteRotationDisabled;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_maxRouteWeight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Max Route Weight'**
|
||||||
|
String get appSettings_maxRouteWeight;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_maxRouteWeightSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Maximum weight a path can accumulate from successful deliveries'**
|
||||||
|
String get appSettings_maxRouteWeightSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_initialRouteWeight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Initial Route Weight'**
|
||||||
|
String get appSettings_initialRouteWeight;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_initialRouteWeightSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Starting weight for newly discovered paths'**
|
||||||
|
String get appSettings_initialRouteWeightSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_routeWeightSuccessIncrement.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Success Weight Increment'**
|
||||||
|
String get appSettings_routeWeightSuccessIncrement;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_routeWeightSuccessIncrementSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Weight added to a path after successful delivery'**
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_routeWeightFailureDecrement.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failure Weight Decrement'**
|
||||||
|
String get appSettings_routeWeightFailureDecrement;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_routeWeightFailureDecrementSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Weight removed from a path after failed delivery'**
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_maxMessageRetries.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Max Message Retries'**
|
||||||
|
String get appSettings_maxMessageRetries;
|
||||||
|
|
||||||
|
/// No description provided for @appSettings_maxMessageRetriesSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Number of retry attempts before marking a message as failed'**
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @path_routeWeight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{weight}/{max}'**
|
||||||
|
String path_routeWeight(String weight, String max);
|
||||||
|
|
||||||
/// No description provided for @appSettings_battery.
|
/// No description provided for @appSettings_battery.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1780,6 +1924,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:
|
||||||
@@ -2752,6 +2962,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:
|
||||||
|
|||||||
@@ -398,6 +398,52 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
String get settings_privacyModeDisabled =>
|
String get settings_privacyModeDisabled =>
|
||||||
'Режим на поверителност е деактивиран';
|
'Режим на поверителност е деактивиран';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Настройки на поверителността';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Контролирайте каква информация се споделя.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Изберете каква информация устройството ви споделя с другите.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Откажи всичко';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Позволи по флагове за контакт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Позволи всичко';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Базов режим на телеметрия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Режим на местоположение на телеметрията';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Режим на средата на телеметрията';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Място на обявата';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Включи местоположение в обявата';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Мулти-потвърди: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Режим на телеметрията е обновен';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Действия';
|
String get settings_actions => 'Действия';
|
||||||
|
|
||||||
@@ -695,6 +741,51 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Автоматично маршрутизирането е деактивирано';
|
'Автоматично маршрутизирането е деактивирано';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight =>
|
||||||
|
'Максимално допустимо тегло на маршрута';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Максималното тегло, което един маршрут може да събере от успешни доставки.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight =>
|
||||||
|
'Първоначална тежест на маршрута';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Начално тегло за новооткрити маршрути';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Увеличение на теглото за успех';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Тегло, добавено към път след успешно доставяне.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Намаляване на теглото, свързано с неуспех';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Тегло, което е било премахнато от пътя след неуспешен опит за доставка.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Максимален брой опити за изпращане на съобщение';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Батерия';
|
String get appSettings_battery => 'Батерия';
|
||||||
|
|
||||||
@@ -944,6 +1035,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 => 'Канали';
|
||||||
|
|
||||||
@@ -1514,6 +1641,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 => 'Етикетиране на пин';
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,50 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Datenschutzmodus deaktiviert';
|
String get settings_privacyModeDisabled => 'Datenschutzmodus deaktiviert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Datenschutzeinstellungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Steuern Sie die Informationen, die freigegeben werden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Wählen Sie die Informationen, die Ihr Gerät mit anderen teilt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Alle ablehnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Zulassen durch Kontaktflaggen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Alles zulassen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Telemetrie-Basismodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Telemetrie-Ortsmodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Telemetrie-Umgebungsmodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Anzeigenort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Ort in der Anzeige einbeziehen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Mehrfach-Bestätigungen: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Telemetriemodus aktualisiert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Aktionen';
|
String get settings_actions => 'Aktionen';
|
||||||
|
|
||||||
@@ -695,6 +739,49 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Automatische Routenrotation deaktiviert';
|
'Automatische Routenrotation deaktiviert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Maximale Gesamtstreckenlänge';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Anfangs-Streckengewicht';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Ausgangsgewicht für neu entdeckte Pfade';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Erhöhung des Erfolgsgewichts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Reduzierung des Gewichts bei Fehlern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Maximale Anzahl an Wiederholungsversuchen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Akku';
|
String get appSettings_battery => 'Akku';
|
||||||
|
|
||||||
@@ -944,6 +1031,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';
|
||||||
|
|
||||||
@@ -1516,6 +1638,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';
|
||||||
|
|
||||||
|
|||||||
@@ -392,6 +392,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Privacy mode disabled';
|
String get settings_privacyModeDisabled => 'Privacy mode disabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Privacy Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => 'Control what information is shared.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Choose what information your device shares with others.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Deny all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Allow by contact flags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Allow all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Telemetry Base Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Telemetry Location Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Telemetry Environment Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Advert Location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Include location in advert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Telemetry mode updated';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Actions';
|
String get settings_actions => 'Actions';
|
||||||
|
|
||||||
@@ -684,6 +726,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Auto route rotation disabled';
|
'Auto route rotation disabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Max Route Weight';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Maximum weight a path can accumulate from successful deliveries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Initial Route Weight';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Starting weight for newly discovered paths';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Success Weight Increment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Weight added to a path after successful delivery';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Failure Weight Decrement';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Weight removed from a path after failed delivery';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries => 'Max Message Retries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Number of retry attempts before marking a message as failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Battery';
|
String get appSettings_battery => 'Battery';
|
||||||
|
|
||||||
@@ -930,6 +1014,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';
|
||||||
|
|
||||||
@@ -1490,6 +1608,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';
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,51 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Modo de privacidad desactivado';
|
String get settings_privacyModeDisabled => 'Modo de privacidad desactivado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Configuración de privacidad';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Controlar qué información se comparte.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Elige qué información comparte tu dispositivo con otros.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Denegar todo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Permitir por banderas de contacto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Permitir todo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Modo base de telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Modo de ubicación de telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Modo de entorno de telemetría';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Ubicación de anuncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Incluir ubicación en anuncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Modo de telemetría actualizado';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Acciones';
|
String get settings_actions => 'Acciones';
|
||||||
|
|
||||||
@@ -694,6 +739,49 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Rotación de ruta automática desactivada';
|
'Rotación de ruta automática desactivada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Peso máximo permitido para la ruta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Peso máximo que una ruta puede acumular gracias a entregas exitosas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Peso inicial de la ruta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Peso inicial para rutas recién descubiertas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Incremento de peso para el éxito';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Peso añadido a una ruta después de una entrega exitosa.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Reducción del peso asociado al fallo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Peso retirado de un camino después de un intento de entrega fallido.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Número máximo de reintentos de envío de mensajes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Número de intentos de reintento antes de marcar un mensaje como fallido.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Batería';
|
String get appSettings_battery => 'Batería';
|
||||||
|
|
||||||
@@ -944,6 +1032,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';
|
||||||
|
|
||||||
@@ -1513,6 +1637,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';
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,52 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get settings_privacyModeDisabled =>
|
String get settings_privacyModeDisabled =>
|
||||||
'Mode de confidentialité désactivé';
|
'Mode de confidentialité désactivé';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Paramètres de confidentialité';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => 'Contrôlez les informations partagées';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Choisissez les informations que votre appareil partage avec les autres.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Refuser tout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Autoriser par drapeaux de contact';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Autoriser tout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Mode de base Télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Mode d\'emplacement de télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Mode d\'environnement de télémétrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Emplacement de l\'annonce';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Inclure l\'emplacement dans l\'annonce';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs : $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated =>
|
||||||
|
'Le mode télémétrie a été mis à jour';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Actions';
|
String get settings_actions => 'Actions';
|
||||||
|
|
||||||
@@ -698,6 +744,50 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Rotation de l\'itinéraire automatique désactivée';
|
'Rotation de l\'itinéraire automatique désactivée';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight =>
|
||||||
|
'Poids maximal autorisé pour le trajet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Poids maximal qu\'un itinéraire peut accumuler grâce à des livraisons réussies.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Poids initial de l\'itinéraire';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Poids de départ pour les nouveaux chemins découverts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Augmentation du poids de réussite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Poids ajouté à un itinéraire après une livraison réussie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Réduction du poids de pénalité';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Poids retiré d\'un itinéraire après une tentative de livraison infructueuse.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Nombre maximal de tentatives de récupération de messages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Nombre de tentatives de relance avant de marquer un message comme ayant échoué.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Batterie';
|
String get appSettings_battery => 'Batterie';
|
||||||
|
|
||||||
@@ -947,6 +1037,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';
|
||||||
|
|
||||||
@@ -1521,6 +1647,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';
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,52 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Modalità privacy disabilitata';
|
String get settings_privacyModeDisabled => 'Modalità privacy disabilitata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Impostazioni sulla privacy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Controlla le informazioni che vengono condivise.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Scegli le informazioni che il tuo dispositivo condivide con gli altri.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Negare tutto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Consenti in base ai flag di contatto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Consenti tutto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Modalità di base di telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Modalità di posizionamento telemetrico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Modalità di ambiente di telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Posizione dell\'annuncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Includi la posizione nell\'annuncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Modalità telemetria aggiornata';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Azioni';
|
String get settings_actions => 'Azioni';
|
||||||
|
|
||||||
@@ -695,6 +741,50 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Rotazione del percorso automatico disabilitata';
|
'Rotazione del percorso automatico disabilitata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight =>
|
||||||
|
'Massimo peso consentito per il percorso';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Il peso massimo che un percorso può accumulare grazie a consegne di successo.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Peso iniziale del percorso';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Peso di partenza per nuovi percorsi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Aumento del peso del successo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Peso aggiunto a un percorso dopo una consegna riuscita.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Riduzione del peso associato al fallimento';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Peso rimosso da un percorso dopo un tentativo di consegna fallito.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Numero massimo di tentativi di invio del messaggio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Numero di tentativi di riprova prima di considerare un messaggio come fallito.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Batteria';
|
String get appSettings_battery => 'Batteria';
|
||||||
|
|
||||||
@@ -943,6 +1033,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';
|
||||||
|
|
||||||
@@ -1513,6 +1639,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';
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,50 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Privacy modus is uitgeschakeld';
|
String get settings_privacyModeDisabled => 'Privacy modus is uitgeschakeld';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Privacyinstellingen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Beheer welke informatie wordt gedeeld';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Kies welke informatie uw apparaat deelt met anderen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Weiger alles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Toestaan op basis van contactvlaggen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Alles toestaan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Telemetrie-basismodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Telemetrie-locatiemodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Telemetrie-omgevingsmodus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Advertentielocatie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Locatie opnemen in advertentie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Telemetrie-modus bijgewerkt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Acties';
|
String get settings_actions => 'Acties';
|
||||||
|
|
||||||
@@ -689,6 +733,49 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Automatische route rotatie is uitgeschakeld';
|
'Automatische route rotatie is uitgeschakeld';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Maximale gewicht voor de route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'เริ่มต้น gewicht van de route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Startgewicht voor nieuwe, ontdekte routes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Toename in het gewicht van het succes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Gewicht wordt toegevoegd aan een route na een succesvolle levering.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Vermindering van het gewicht van fouten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Gewicht verwijderd van een pad na een mislukte levering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Aantal pogingen om berichten te versturen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Batterij';
|
String get appSettings_battery => 'Batterij';
|
||||||
|
|
||||||
@@ -937,6 +1024,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';
|
||||||
|
|
||||||
@@ -1505,6 +1626,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';
|
||||||
|
|
||||||
|
|||||||
@@ -401,6 +401,52 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Tryb prywatności wyłączony';
|
String get settings_privacyModeDisabled => 'Tryb prywatności wyłączony';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Ustawienia prywatności';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Kontroluj jakie informacje są udostępniane.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Wybierz jakie informacje urządzenie udostępni innym.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Odmów wszystkim';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Zezwalaj według flag kontaktowych';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Zezwalaj na wszystko';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Tryb podstawowy telemetrii';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Tryb położenia telemetrycznego';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Tryb środowiska telemetrycznego';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Lokalizacja reklamowa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Uwzględnij lokalizację w ogłoszeniu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Wiele potwierdzeń: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated =>
|
||||||
|
'Tryb telemetryczny zaktualizowany';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Działania';
|
String get settings_actions => 'Działania';
|
||||||
|
|
||||||
@@ -698,6 +744,49 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Automatyczne obracanie tras wyłączone';
|
'Automatyczne obracanie tras wyłączone';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight =>
|
||||||
|
'Maksymalny dopuszczalny ciężar pojazdu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Maksymalna waga, jaką ścieżka może zgromadzić dzięki udanym dostawom.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Początkowa waga trasy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Początkowa waga dla nowych, odkrytych ścieżek';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement => 'Wzrost wagi sukcesu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Waga dodana do ścieżki po pomyślnym dostarczeniu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Zmniejszenie wagi kary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Waga usunięta z trasy po nieudanej dostawie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Maksymalna liczba prób wysłania wiadomości';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Bateria';
|
String get appSettings_battery => 'Bateria';
|
||||||
|
|
||||||
@@ -946,6 +1035,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';
|
||||||
|
|
||||||
@@ -1515,6 +1640,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ę';
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,51 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Modo de privacidade desativado';
|
String get settings_privacyModeDisabled => 'Modo de privacidade desativado';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Configurações de Privacidade';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => 'Controle o que é compartilhado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Escolha quais informações o seu dispositivo compartilha com os outros.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Negar todos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Permitir por bandeiras de contato';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Permitir todos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Modo Base de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Modo de Localização de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Modo de Ambiente de Telemetria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Localização do Anúncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Incluir localização no anúncio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Modo de telemetria atualizado';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Ações';
|
String get settings_actions => 'Ações';
|
||||||
|
|
||||||
@@ -696,6 +741,49 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Rotação de roteamento automático desativada';
|
'Rotação de roteamento automático desativada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Peso Máximo da Rota';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Peso Inicial da Rota';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Peso inicial para novos caminhos descobertos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Aumento do peso para indicar sucesso';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Peso adicionado a um caminho após a entrega bem-sucedida.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Redução do peso da falha';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Peso removido de um caminho após uma tentativa de entrega malsucedida.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Número máximo de tentativas de envio de mensagens';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Número de tentativas de reenvio antes de classificar uma mensagem como falha.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Bateria';
|
String get appSettings_battery => 'Bateria';
|
||||||
|
|
||||||
@@ -945,6 +1033,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';
|
||||||
|
|
||||||
@@ -1514,6 +1638,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';
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,51 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get settings_privacyModeDisabled =>
|
String get settings_privacyModeDisabled =>
|
||||||
'Режим конфиденциальности выключен';
|
'Режим конфиденциальности выключен';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Настройки конфиденциальности';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Контролируйте, какую информацию делиться.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Выберите, какую информацию ваше устройство будет делиться с другими.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Отклонить все';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Разрешить по флагам контактов';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Разрешить все';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Базовый режим телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode =>
|
||||||
|
'Режим местоположения телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Режим среды телеметрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Местоположение рекламы';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Включить местоположение в объявление';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Мульти-ACK: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Режим телеметрии обновлен';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Действия';
|
String get settings_actions => 'Действия';
|
||||||
|
|
||||||
@@ -696,6 +741,50 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Автоматическое переключение маршрутов отключено';
|
'Автоматическое переключение маршрутов отключено';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight =>
|
||||||
|
'Максимальный допустимый вес маршрута';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Начальный вес маршрута';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Начальный вес для новых, только что открытых маршрутов';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Увеличение веса успеха';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Вес, добавленный к маршруту после успешной доставки.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Уменьшение веса неудачи';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Вес, который был удален с пути после неудачной доставки.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Максимальное количество повторных попыток отправки сообщения';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Батарея';
|
String get appSettings_battery => 'Батарея';
|
||||||
|
|
||||||
@@ -944,6 +1033,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 => 'Каналы';
|
||||||
|
|
||||||
@@ -1516,6 +1641,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 => 'Метка';
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,49 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Ochranný režim je vypnutý';
|
String get settings_privacyModeDisabled => 'Ochranný režim je vypnutý';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Nastavenia súkromia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => 'Ovládni, aké informácie sa zdieľajú.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Vyberte, ktoré informácie váš zariadenie zdieľa s ostatnými.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Zamietnuť všetko';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Povoliť podľa kontaktových vlajok';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Povoliť všetko';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Základný režim telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Režim umiestnenia telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Režim prostredia telemetrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Umiestnenie inzerátu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Zahrnúť polohu do inzerátu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Viaceré ACK: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated =>
|
||||||
|
'Režim telemetrie bol aktualizovaný';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Možné akcie';
|
String get settings_actions => 'Možné akcie';
|
||||||
|
|
||||||
@@ -687,6 +730,48 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Automatické prekladanie trás pozastavené';
|
'Automatické prekladanie trás pozastavené';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Maximálna hmotnosť trasy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Počiatočná váha trasy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Počiatočná váha pre nové, objavené cesty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement => 'Zvyšenie váhy úspechu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Hmotnosť pridaná k trase po úspešnej doručení';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Sníženie váhy, ktorá sa používa na odhad rizika.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Hmotnosť odstránená z cesty po neúspešnej doručenie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Maximalný počet pokusov o doručenie správ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Počet pokusov o odošleť pred označením správy ako neúspešnej';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Batéria';
|
String get appSettings_battery => 'Batéria';
|
||||||
|
|
||||||
@@ -938,6 +1023,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';
|
||||||
|
|
||||||
@@ -1507,6 +1627,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';
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,50 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Privatni način je onemogočen.';
|
String get settings_privacyModeDisabled => 'Privatni način je onemogočen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Nastavitve zasebnosti';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Kontrolirajte, katere informacije so deljene.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Izberite, katere informacije vaš naprava deli z drugimi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Zavrniti vse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Dovoli po kontaktnih zastavah';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Dovoli vse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Osnovni način telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Način delovanja telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode =>
|
||||||
|
'Način delovanja okolja telemetrije';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Lokacija oglasa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Vključi lokacijo v oglas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Večkratni potrditvi: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Način telemetrije posodobljen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Akcije';
|
String get settings_actions => 'Akcije';
|
||||||
|
|
||||||
@@ -687,6 +731,49 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Samodejno krmilno rotiranje je onemogočeno';
|
'Samodejno krmilno rotiranje je onemogočeno';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Največja dovoljena teža poti';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Izvirna teža poti';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Izguba teže za nove, odkriti poti';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Učinkovitost: povečanje';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Težava, dodana poti po uspešni dostavi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Zmanjšanje teže, ki je povezana s pomanjkanjem';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Težo, ki ni bila uspešno dostavljena, odstranili s poti.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Najve število poskusov pošiljanja sporočil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Baterija';
|
String get appSettings_battery => 'Baterija';
|
||||||
|
|
||||||
@@ -934,6 +1021,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';
|
||||||
|
|
||||||
@@ -1501,6 +1623,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';
|
||||||
|
|
||||||
|
|||||||
@@ -392,6 +392,49 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Privatläge är avstängt';
|
String get settings_privacyModeDisabled => 'Privatläge är avstängt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Inställningar för sekretess';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Kontrollera vilken information som delas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Välj vilken information din enhet delar med andra.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Neka alla';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Tillåt via kontaktflaggor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Tillåt alla';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Telemetribasläge';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Telemetritillstånd för plats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Telemetri miljöläge';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Annonsplacering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => 'Inkludera plats i annonsen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Multi-ACKs: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Telemetri-läge uppdaterat';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Åtgärder';
|
String get settings_actions => 'Åtgärder';
|
||||||
|
|
||||||
@@ -682,6 +725,48 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Automatisk ruttrotation är avstängd';
|
'Automatisk ruttrotation är avstängd';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Maximalt tillåtet vikt för rutten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Initial vikt för rutt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Initial vikt för nyligen upptäckta vägar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Ökning av vikt för framgång';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Vikt läggs till en väg efter en lyckad leverans.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Minskning av vikten för misslyckande';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Vikt som tagits bort från en väg efter ett misslyckat leveransförsök';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries => 'Maximalt antal försök';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Antal försök att skicka om ett meddelande innan det markeras som misslyckat.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Batteri';
|
String get appSettings_battery => 'Batteri';
|
||||||
|
|
||||||
@@ -930,6 +1015,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';
|
||||||
|
|
||||||
@@ -1497,6 +1616,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';
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,50 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => 'Режим приватності вимкнено';
|
String get settings_privacyModeDisabled => 'Режим приватності вимкнено';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => 'Налаштування приватності';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle =>
|
||||||
|
'Керуйте інформацією, яку буде спільно використовуватися';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription =>
|
||||||
|
'Виберіть, яку інформацію ваш пристрій буде передавати іншим.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => 'Відхилити все';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => 'Дозволити за контактними прапорцями';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => 'Дозволити все';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => 'Режим базової телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => 'Режим місця телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => 'Режим середовища телеметрії';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => 'Розміщення реклами';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle =>
|
||||||
|
'Включити місце розташування в оголошення';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return 'Багатократне підтвердження: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => 'Режим телеметрії оновлено';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => 'Дії';
|
String get settings_actions => 'Дії';
|
||||||
|
|
||||||
@@ -692,6 +736,49 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
String get appSettings_autoRouteRotationDisabled =>
|
String get appSettings_autoRouteRotationDisabled =>
|
||||||
'Авторотація маршрутизації вимкнена';
|
'Авторотація маршрутизації вимкнена';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => 'Максимальна вага маршруту';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle =>
|
||||||
|
'Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => 'Початкова вартість маршруту';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle =>
|
||||||
|
'Початкова вага для нових відкритих шляхів';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement =>
|
||||||
|
'Збільшення ваги успіху';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'Вага, додана до маршруту після успішної доставки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement =>
|
||||||
|
'Зменшення ваги помилки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'Вага, яка була знята з маршруту після невдалої доставки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries =>
|
||||||
|
'Максимальна кількість повторних спроб надсилання повідомлення';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle =>
|
||||||
|
'Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => 'Батарея';
|
String get appSettings_battery => 'Батарея';
|
||||||
|
|
||||||
@@ -940,6 +1027,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 => 'Канали';
|
||||||
|
|
||||||
@@ -1513,6 +1636,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 => 'Мітка піна';
|
||||||
|
|
||||||
|
|||||||
@@ -374,6 +374,47 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settings_privacyModeDisabled => '隐私模式已关闭';
|
String get settings_privacyModeDisabled => '隐私模式已关闭';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacy => '隐私设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySubtitle => '控制要共享的信息。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_privacySettingsDescription => '选择您的设备与他人共享的信息。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_denyAll => '拒绝所有';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowByContact => '按联系人标志允许';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_allowAll => '允许全部';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryBaseMode => '遥测基础模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryLocationMode => '遥测位置模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryEnvironmentMode => '遥测环境模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocation => '广告位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_advertLocationSubtitle => '在广告中包含位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settings_multiAck(String value) {
|
||||||
|
return '多重ACK:$value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings_telemetryModeUpdated => '遥测模式已更新';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settings_actions => '操作';
|
String get settings_actions => '操作';
|
||||||
|
|
||||||
@@ -648,6 +689,43 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_autoRouteRotationDisabled => '自动路径轮换已禁用';
|
String get appSettings_autoRouteRotationDisabled => '自动路径轮换已禁用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeight => '最大路径重量';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxRouteWeightSubtitle => '一条路径可以累积的最大重量,取决于成功交付的数量。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeight => '初始路线权重';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_initialRouteWeightSubtitle => '新发现路径的初始重量';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrement => '成功权重增加';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||||
|
'在成功交付后,将重量添加到路径中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrement => '失败权重降低';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||||
|
'从一条路径上移除的货物,由于无法成功交付而移除。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetries => '最大消息重试次数';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appSettings_maxMessageRetriesSubtitle => '在将消息标记为失败之前,允许尝试的次数';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String path_routeWeight(String weight, String max) {
|
||||||
|
return '$weight/$max';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appSettings_battery => '电池';
|
String get appSettings_battery => '电池';
|
||||||
|
|
||||||
@@ -886,6 +964,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 => '频道';
|
||||||
|
|
||||||
@@ -1424,6 +1535,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 => '标签';
|
||||||
|
|
||||||
|
|||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
|
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
|
||||||
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
||||||
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
||||||
"map_showDiscoveryContacts": "Ontdek contacten weergeven"
|
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
|
||||||
}
|
"map_setAsMyLocation": "Stel dit in als mijn locatie",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.",
|
||||||
|
"appSettings_initialRouteWeight": "เริ่มต้น gewicht van de route",
|
||||||
|
"appSettings_maxRouteWeight": "Maximale gewicht voor de route",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Startgewicht voor nieuwe, ontdekte routes",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Toename in het gewicht van het succes",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht wordt toegevoegd aan een route na een succesvolle levering.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Vermindering van het gewicht van fouten",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht verwijderd van een pad na een mislukte levering",
|
||||||
|
"appSettings_maxMessageRetries": "Aantal pogingen om berichten te versturen",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Telemetrie-modus bijgewerkt",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
|
}
|
||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
|
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
|
||||||
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
||||||
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania"
|
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
|
||||||
}
|
"map_setAsMyLocation": "Ustaw jako moje lokalizację",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_initialRouteWeight": "Początkowa waga trasy",
|
||||||
|
"appSettings_maxRouteWeight": "Maksymalny dopuszczalny ciężar pojazdu",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Początkowa waga dla nowych, odkrytych ścieżek",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Maksymalna waga, jaką ścieżka może zgromadzić dzięki udanym dostawom.",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Wzrost wagi sukcesu",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Waga dodana do ścieżki po pomyślnym dostarczeniu",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Zmniejszenie wagi kary",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Waga usunięta z trasy po nieudanej dostawie",
|
||||||
|
"appSettings_maxMessageRetries": "Maksymalna liczba prób wysłania wiadomości",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Tryb telemetryczny zaktualizowany",
|
||||||
|
"settings_multiAck": "Wiele potwierdzeń: {value}"
|
||||||
|
}
|
||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
|
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
|
||||||
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
||||||
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta"
|
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
|
||||||
}
|
"map_setAsMyLocation": "Defina minha localização",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_initialRouteWeight": "Peso Inicial da Rota",
|
||||||
|
"appSettings_maxRouteWeight": "Peso Máximo da Rota",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Peso inicial para novos caminhos descobertos",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Aumento do peso para indicar sucesso",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso adicionado a um caminho após a entrega bem-sucedida.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Redução do peso da falha",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Peso removido de um caminho após uma tentativa de entrega malsucedida.",
|
||||||
|
"appSettings_maxMessageRetries": "Número máximo de tentativas de envio de mensagens",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Número de tentativas de reenvio antes de classificar uma mensagem como falha.",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Modo de telemetria atualizado",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
|
}
|
||||||
+55
-2
@@ -1128,5 +1128,58 @@
|
|||||||
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
|
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
|
||||||
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
||||||
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Показать контакты Discovery"
|
"map_showDiscoveryContacts": "Показать контакты Discovery",
|
||||||
}
|
"map_setAsMyLocation": "Установить мое местоположение",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_maxRouteWeight": "Максимальный допустимый вес маршрута",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Начальный вес для новых, только что открытых маршрутов",
|
||||||
|
"appSettings_initialRouteWeight": "Начальный вес маршрута",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Увеличение веса успеха",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Вес, добавленный к маршруту после успешной доставки.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Уменьшение веса неудачи",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Вес, который был удален с пути после неудачной доставки.",
|
||||||
|
"appSettings_maxMessageRetries": "Максимальное количество повторных попыток отправки сообщения",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
|
||||||
|
"settings_multiAck": "Мульти-ACK: {value}"
|
||||||
|
}
|
||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
|
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
|
||||||
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
||||||
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
||||||
"map_showDiscoveryContacts": "Zobraziť kontakty objavov"
|
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
|
||||||
}
|
"map_setAsMyLocation": "Nastavte ako moju polohu",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Počiatočná váha pre nové, objavené cesty",
|
||||||
|
"appSettings_initialRouteWeight": "Počiatočná váha trasy",
|
||||||
|
"appSettings_maxRouteWeight": "Maximálna hmotnosť trasy",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Zvyšenie váhy úspechu",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Hmotnosť pridaná k trase po úspešnej doručení",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Sníženie váhy, ktorá sa používa na odhad rizika.",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Hmotnosť odstránená z cesty po neúspešnej doručenie",
|
||||||
|
"appSettings_maxMessageRetries": "Maximalný počet pokusov o doručenie správ",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Počet pokusov o odošleť pred označením správy ako neúspešnej",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Režim telemetrie bol aktualizovaný",
|
||||||
|
"settings_multiAck": "Viaceré ACK: {value}"
|
||||||
|
}
|
||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
|
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
|
||||||
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
||||||
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov"
|
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
|
||||||
}
|
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.",
|
||||||
|
"appSettings_initialRouteWeight": "Izvirna teža poti",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Izguba teže za nove, odkriti poti",
|
||||||
|
"appSettings_maxRouteWeight": "Največja dovoljena teža poti",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Učinkovitost: povečanje",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Težava, dodana poti po uspešni dostavi",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Zmanjšanje teže, ki je povezana s pomanjkanjem",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Težo, ki ni bila uspešno dostavljena, odstranili s poti.",
|
||||||
|
"appSettings_maxMessageRetries": "Najve število poskusov pošiljanja sporočil",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_multiAck": "Večkratni potrditvi: {value}",
|
||||||
|
"settings_telemetryModeUpdated": "Način telemetrije posodobljen"
|
||||||
|
}
|
||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
|
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
|
||||||
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
||||||
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
||||||
"map_showDiscoveryContacts": "Visa Discovery-kontakter"
|
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
|
||||||
}
|
"map_setAsMyLocation": "Ange som min plats",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Initial vikt för nyligen upptäckta vägar",
|
||||||
|
"appSettings_maxRouteWeight": "Maximalt tillåtet vikt för rutten",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.",
|
||||||
|
"appSettings_initialRouteWeight": "Initial vikt för rutt",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Ökning av vikt för framgång",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Vikt läggs till en väg efter en lyckad leverans.",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Minskning av vikten för misslyckande",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Vikt som tagits bort från en väg efter ett misslyckat leveransförsök",
|
||||||
|
"appSettings_maxMessageRetries": "Maximalt antal försök",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Antal försök att skicka om ett meddelande innan det markeras som misslyckat.",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Telemetri-läge uppdaterat",
|
||||||
|
"settings_multiAck": "Multi-ACKs: {value}"
|
||||||
|
}
|
||||||
+55
-2
@@ -1888,5 +1888,58 @@
|
|||||||
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
|
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
|
||||||
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
||||||
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Показати контакти Відкриття"
|
"map_showDiscoveryContacts": "Показати контакти Відкриття",
|
||||||
}
|
"map_setAsMyLocation": "Встановити моє місцезнаходження",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_initialRouteWeight": "Початкова вартість маршруту",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "Початкова вага для нових відкритих шляхів",
|
||||||
|
"appSettings_maxRouteWeight": "Максимальна вага маршруту",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "Збільшення ваги успіху",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "Вага, додана до маршруту після успішної доставки",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "Зменшення ваги помилки",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "Вага, яка була знята з маршруту після невдалої доставки",
|
||||||
|
"appSettings_maxMessageRetries": "Максимальна кількість повторних спроб надсилання повідомлення",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_telemetryModeUpdated": "Режим телеметрії оновлено",
|
||||||
|
"settings_multiAck": "Багатократне підтвердження: {value}"
|
||||||
|
}
|
||||||
+55
-2
@@ -1893,5 +1893,58 @@
|
|||||||
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
|
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
|
||||||
"tcpErrorTimedOut": "TCP 连接超时。",
|
"tcpErrorTimedOut": "TCP 连接超时。",
|
||||||
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
||||||
"map_showDiscoveryContacts": "显示发现联系人"
|
"map_showDiscoveryContacts": "显示发现联系人",
|
||||||
}
|
"map_setAsMyLocation": "设置为我的位置",
|
||||||
|
"@path_routeWeight": {
|
||||||
|
"placeholders": {
|
||||||
|
"weight": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appSettings_maxRouteWeight": "最大路径重量",
|
||||||
|
"appSettings_initialRouteWeightSubtitle": "新发现路径的初始重量",
|
||||||
|
"appSettings_initialRouteWeight": "初始路线权重",
|
||||||
|
"appSettings_maxRouteWeightSubtitle": "一条路径可以累积的最大重量,取决于成功交付的数量。",
|
||||||
|
"appSettings_routeWeightSuccessIncrement": "成功权重增加",
|
||||||
|
"appSettings_routeWeightSuccessIncrementSubtitle": "在成功交付后,将重量添加到路径中",
|
||||||
|
"appSettings_routeWeightFailureDecrement": "失败权重降低",
|
||||||
|
"appSettings_routeWeightFailureDecrementSubtitle": "从一条路径上移除的货物,由于无法成功交付而移除。",
|
||||||
|
"appSettings_maxMessageRetries": "最大消息重试次数",
|
||||||
|
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
|
||||||
|
"path_routeWeight": "{weight}/{max}",
|
||||||
|
"settings_multiAck": "多重ACK:{value}",
|
||||||
|
"settings_telemetryModeUpdated": "遥测模式已更新"
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ 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/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';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ void main() async {
|
|||||||
final mapTileCacheService = MapTileCacheService();
|
final mapTileCacheService = MapTileCacheService();
|
||||||
final chatTextScaleService = ChatTextScaleService();
|
final chatTextScaleService = ChatTextScaleService();
|
||||||
final uiViewStateService = UiViewStateService();
|
final uiViewStateService = UiViewStateService();
|
||||||
|
final timeoutPredictionService = TimeoutPredictionService(storage);
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
await appSettingsService.loadSettings();
|
await appSettingsService.loadSettings();
|
||||||
@@ -59,6 +61,7 @@ void main() async {
|
|||||||
|
|
||||||
await chatTextScaleService.initialize();
|
await chatTextScaleService.initialize();
|
||||||
await uiViewStateService.initialize();
|
await uiViewStateService.initialize();
|
||||||
|
await timeoutPredictionService.initialize();
|
||||||
|
|
||||||
// Wire up connector with services
|
// Wire up connector with services
|
||||||
connector.initialize(
|
connector.initialize(
|
||||||
@@ -68,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();
|
||||||
@@ -90,6 +94,7 @@ void main() async {
|
|||||||
mapTileCacheService: mapTileCacheService,
|
mapTileCacheService: mapTileCacheService,
|
||||||
chatTextScaleService: chatTextScaleService,
|
chatTextScaleService: chatTextScaleService,
|
||||||
uiViewStateService: uiViewStateService,
|
uiViewStateService: uiViewStateService,
|
||||||
|
timeoutPredictionService: timeoutPredictionService,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,6 +131,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
final MapTileCacheService mapTileCacheService;
|
final MapTileCacheService mapTileCacheService;
|
||||||
final ChatTextScaleService chatTextScaleService;
|
final ChatTextScaleService chatTextScaleService;
|
||||||
final UiViewStateService uiViewStateService;
|
final UiViewStateService uiViewStateService;
|
||||||
|
final TimeoutPredictionService timeoutPredictionService;
|
||||||
|
|
||||||
const MeshCoreApp({
|
const MeshCoreApp({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -139,6 +145,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
required this.mapTileCacheService,
|
required this.mapTileCacheService,
|
||||||
required this.chatTextScaleService,
|
required this.chatTextScaleService,
|
||||||
required this.uiViewStateService,
|
required this.uiViewStateService,
|
||||||
|
required this.timeoutPredictionService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -155,6 +162,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider.value(value: uiViewStateService),
|
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) {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class AppSettings {
|
|||||||
final bool notifyOnNewChannelMessage;
|
final bool notifyOnNewChannelMessage;
|
||||||
final bool notifyOnNewAdvert;
|
final bool notifyOnNewAdvert;
|
||||||
final bool autoRouteRotationEnabled;
|
final bool autoRouteRotationEnabled;
|
||||||
|
final double maxRouteWeight;
|
||||||
|
final double initialRouteWeight;
|
||||||
|
final double routeWeightSuccessIncrement;
|
||||||
|
final double routeWeightFailureDecrement;
|
||||||
|
final int maxMessageRetries;
|
||||||
final String themeMode;
|
final String themeMode;
|
||||||
final String? languageOverride; // null = system default
|
final String? languageOverride; // null = system default
|
||||||
final bool appDebugLogEnabled;
|
final bool appDebugLogEnabled;
|
||||||
@@ -40,6 +45,8 @@ class AppSettings {
|
|||||||
final UnitSystem unitSystem;
|
final UnitSystem unitSystem;
|
||||||
final Set<String> mutedChannels;
|
final Set<String> mutedChannels;
|
||||||
final bool mapShowDiscoveryContacts;
|
final bool mapShowDiscoveryContacts;
|
||||||
|
final String tcpServerAddress;
|
||||||
|
final int tcpServerPort;
|
||||||
|
|
||||||
AppSettings({
|
AppSettings({
|
||||||
this.clearPathOnMaxRetry = false,
|
this.clearPathOnMaxRetry = false,
|
||||||
@@ -60,6 +67,11 @@ class AppSettings {
|
|||||||
this.notifyOnNewChannelMessage = true,
|
this.notifyOnNewChannelMessage = true,
|
||||||
this.notifyOnNewAdvert = true,
|
this.notifyOnNewAdvert = true,
|
||||||
this.autoRouteRotationEnabled = false,
|
this.autoRouteRotationEnabled = false,
|
||||||
|
this.maxRouteWeight = 5.0,
|
||||||
|
this.initialRouteWeight = 3.0,
|
||||||
|
this.routeWeightSuccessIncrement = 0.5,
|
||||||
|
this.routeWeightFailureDecrement = 0.2,
|
||||||
|
this.maxMessageRetries = 5,
|
||||||
this.themeMode = 'system',
|
this.themeMode = 'system',
|
||||||
this.languageOverride,
|
this.languageOverride,
|
||||||
this.appDebugLogEnabled = false,
|
this.appDebugLogEnabled = false,
|
||||||
@@ -68,6 +80,8 @@ class AppSettings {
|
|||||||
this.unitSystem = UnitSystem.metric,
|
this.unitSystem = UnitSystem.metric,
|
||||||
Set<String>? mutedChannels,
|
Set<String>? mutedChannels,
|
||||||
this.mapShowDiscoveryContacts = true,
|
this.mapShowDiscoveryContacts = true,
|
||||||
|
this.tcpServerAddress = '',
|
||||||
|
this.tcpServerPort = 0,
|
||||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
||||||
mutedChannels = mutedChannels ?? {};
|
mutedChannels = mutedChannels ?? {};
|
||||||
@@ -92,6 +106,11 @@ class AppSettings {
|
|||||||
'notify_on_new_channel_message': notifyOnNewChannelMessage,
|
'notify_on_new_channel_message': notifyOnNewChannelMessage,
|
||||||
'notify_on_new_advert': notifyOnNewAdvert,
|
'notify_on_new_advert': notifyOnNewAdvert,
|
||||||
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
||||||
|
'max_route_weight': maxRouteWeight,
|
||||||
|
'initial_route_weight': initialRouteWeight,
|
||||||
|
'route_weight_success_increment': routeWeightSuccessIncrement,
|
||||||
|
'route_weight_failure_decrement': routeWeightFailureDecrement,
|
||||||
|
'max_message_retries': maxMessageRetries,
|
||||||
'theme_mode': themeMode,
|
'theme_mode': themeMode,
|
||||||
'language_override': languageOverride,
|
'language_override': languageOverride,
|
||||||
'app_debug_log_enabled': appDebugLogEnabled,
|
'app_debug_log_enabled': appDebugLogEnabled,
|
||||||
@@ -100,6 +119,8 @@ class AppSettings {
|
|||||||
'unit_system': unitSystem.value,
|
'unit_system': unitSystem.value,
|
||||||
'muted_channels': mutedChannels.toList(),
|
'muted_channels': mutedChannels.toList(),
|
||||||
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
||||||
|
'tcp_server_address': tcpServerAddress,
|
||||||
|
'tcp_server_port': tcpServerPort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +157,14 @@ class AppSettings {
|
|||||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||||
autoRouteRotationEnabled:
|
autoRouteRotationEnabled:
|
||||||
json['auto_route_rotation_enabled'] as bool? ?? false,
|
json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||||
|
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
|
||||||
|
initialRouteWeight:
|
||||||
|
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
|
||||||
|
routeWeightSuccessIncrement:
|
||||||
|
(json['route_weight_success_increment'] as num?)?.toDouble() ?? 0.5,
|
||||||
|
routeWeightFailureDecrement:
|
||||||
|
(json['route_weight_failure_decrement'] as num?)?.toDouble() ?? 0.2,
|
||||||
|
maxMessageRetries: json['max_message_retries'] as int? ?? 5,
|
||||||
themeMode: json['theme_mode'] as String? ?? 'system',
|
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||||
languageOverride: json['language_override'] as String?,
|
languageOverride: json['language_override'] as String?,
|
||||||
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
|
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
|
||||||
@@ -157,6 +186,8 @@ class AppSettings {
|
|||||||
{},
|
{},
|
||||||
mapShowDiscoveryContacts:
|
mapShowDiscoveryContacts:
|
||||||
json['map_show_discovery_contacts'] as bool? ?? true,
|
json['map_show_discovery_contacts'] as bool? ?? true,
|
||||||
|
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
|
||||||
|
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +210,11 @@ class AppSettings {
|
|||||||
bool? notifyOnNewChannelMessage,
|
bool? notifyOnNewChannelMessage,
|
||||||
bool? notifyOnNewAdvert,
|
bool? notifyOnNewAdvert,
|
||||||
bool? autoRouteRotationEnabled,
|
bool? autoRouteRotationEnabled,
|
||||||
|
double? maxRouteWeight,
|
||||||
|
double? initialRouteWeight,
|
||||||
|
double? routeWeightSuccessIncrement,
|
||||||
|
double? routeWeightFailureDecrement,
|
||||||
|
int? maxMessageRetries,
|
||||||
String? themeMode,
|
String? themeMode,
|
||||||
Object? languageOverride = _unset,
|
Object? languageOverride = _unset,
|
||||||
bool? appDebugLogEnabled,
|
bool? appDebugLogEnabled,
|
||||||
@@ -187,6 +223,8 @@ class AppSettings {
|
|||||||
UnitSystem? unitSystem,
|
UnitSystem? unitSystem,
|
||||||
Set<String>? mutedChannels,
|
Set<String>? mutedChannels,
|
||||||
bool? mapShowDiscoveryContacts,
|
bool? mapShowDiscoveryContacts,
|
||||||
|
String? tcpServerAddress,
|
||||||
|
int? tcpServerPort,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||||
@@ -212,6 +250,13 @@ class AppSettings {
|
|||||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||||
autoRouteRotationEnabled:
|
autoRouteRotationEnabled:
|
||||||
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||||
|
maxRouteWeight: maxRouteWeight ?? this.maxRouteWeight,
|
||||||
|
initialRouteWeight: initialRouteWeight ?? this.initialRouteWeight,
|
||||||
|
routeWeightSuccessIncrement:
|
||||||
|
routeWeightSuccessIncrement ?? this.routeWeightSuccessIncrement,
|
||||||
|
routeWeightFailureDecrement:
|
||||||
|
routeWeightFailureDecrement ?? this.routeWeightFailureDecrement,
|
||||||
|
maxMessageRetries: maxMessageRetries ?? this.maxMessageRetries,
|
||||||
themeMode: themeMode ?? this.themeMode,
|
themeMode: themeMode ?? this.themeMode,
|
||||||
languageOverride: languageOverride == _unset
|
languageOverride: languageOverride == _unset
|
||||||
? this.languageOverride
|
? this.languageOverride
|
||||||
@@ -225,6 +270,8 @@ class AppSettings {
|
|||||||
mutedChannels: mutedChannels ?? this.mutedChannels,
|
mutedChannels: mutedChannels ?? this.mutedChannels,
|
||||||
mapShowDiscoveryContacts:
|
mapShowDiscoveryContacts:
|
||||||
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
||||||
|
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
|
||||||
|
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-9
@@ -24,20 +24,23 @@ class Channel {
|
|||||||
|
|
||||||
bool get isPublicChannel => pskHex == publicChannelPsk;
|
bool get isPublicChannel => pskHex == publicChannelPsk;
|
||||||
|
|
||||||
static Channel? fromFrame(Uint8List data) {
|
static Channel? fromFrame(Uint8List frame) {
|
||||||
// CHANNEL_INFO format:
|
// CHANNEL_INFO format:
|
||||||
// [0] = RESP_CODE_CHANNEL_INFO (18)
|
// [0] = RESP_CODE_CHANNEL_INFO (18)
|
||||||
// [1] = channel_idx
|
// [1] = channel_idx
|
||||||
// [2-33] = name (32 bytes, null-terminated)
|
// [2-33] = name (32 bytes, null-terminated)
|
||||||
// [34-49] = psk (16 bytes)
|
// [34-49] = psk (16 bytes)
|
||||||
if (data.length < 50) return null;
|
if (frame.length < 50) return null;
|
||||||
if (data[0] != respCodeChannelInfo) return null;
|
final reader = BufferReader(frame);
|
||||||
|
try {
|
||||||
final index = data[1];
|
if (reader.readByte() != respCodeChannelInfo) return null;
|
||||||
final name = readCString(data, 2, 32);
|
final index = reader.readByte();
|
||||||
final psk = Uint8List.fromList(data.sublist(34, 50));
|
final name = reader.readCStringGreedy(32);
|
||||||
|
final psk = reader.readBytes(16);
|
||||||
return Channel(index: index, name: name, psk: psk);
|
return Channel(index: index, name: name, psk: psk);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Channel empty(int index) {
|
static Channel empty(int index) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../helpers/reaction_helper.dart';
|
import '../helpers/reaction_helper.dart';
|
||||||
import '../helpers/smaz.dart';
|
import '../helpers/smaz.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
|
||||||
enum ChannelMessageStatus { pending, sent, failed }
|
enum ChannelMessageStatus { pending, sent, failed }
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ class ChannelMessage {
|
|||||||
final List<Uint8List> pathVariants;
|
final List<Uint8List> pathVariants;
|
||||||
final int? channelIndex;
|
final int? channelIndex;
|
||||||
final String messageId;
|
final String messageId;
|
||||||
|
final String? packetHash;
|
||||||
final String? replyToMessageId;
|
final String? replyToMessageId;
|
||||||
final String? replyToSenderName;
|
final String? replyToSenderName;
|
||||||
final String? replyToText;
|
final String? replyToText;
|
||||||
@@ -55,6 +57,7 @@ class ChannelMessage {
|
|||||||
List<Uint8List>? pathVariants,
|
List<Uint8List>? pathVariants,
|
||||||
this.channelIndex,
|
this.channelIndex,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
|
this.packetHash,
|
||||||
this.replyToMessageId,
|
this.replyToMessageId,
|
||||||
this.replyToSenderName,
|
this.replyToSenderName,
|
||||||
this.replyToText,
|
this.replyToText,
|
||||||
@@ -79,6 +82,7 @@ class ChannelMessage {
|
|||||||
int? pathLength,
|
int? pathLength,
|
||||||
Uint8List? pathBytes,
|
Uint8List? pathBytes,
|
||||||
List<Uint8List>? pathVariants,
|
List<Uint8List>? pathVariants,
|
||||||
|
String? packetHash,
|
||||||
String? replyToMessageId,
|
String? replyToMessageId,
|
||||||
String? replyToSenderName,
|
String? replyToSenderName,
|
||||||
String? replyToText,
|
String? replyToText,
|
||||||
@@ -98,6 +102,7 @@ class ChannelMessage {
|
|||||||
pathVariants: pathVariants ?? this.pathVariants,
|
pathVariants: pathVariants ?? this.pathVariants,
|
||||||
channelIndex: channelIndex,
|
channelIndex: channelIndex,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
packetHash: packetHash ?? this.packetHash,
|
||||||
replyToMessageId: replyToMessageId ?? this.replyToMessageId,
|
replyToMessageId: replyToMessageId ?? this.replyToMessageId,
|
||||||
replyToSenderName: replyToSenderName ?? this.replyToSenderName,
|
replyToSenderName: replyToSenderName ?? this.replyToSenderName,
|
||||||
replyToText: replyToText ?? this.replyToText,
|
replyToText: replyToText ?? this.replyToText,
|
||||||
@@ -105,89 +110,82 @@ class ChannelMessage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static ChannelMessage? fromFrame(Uint8List data) {
|
static ChannelMessage? fromFrame(Uint8List frame) {
|
||||||
// CHANNEL_MSG_RECV format varies by version:
|
// CHANNEL_MSG_RECV format varies by version:
|
||||||
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
|
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
|
||||||
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
|
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
|
||||||
if (data.length < 8) return null;
|
if (frame.length < 8) return null;
|
||||||
|
try {
|
||||||
|
final reader = BufferReader(frame);
|
||||||
|
final code = reader.readByte();
|
||||||
|
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final code = data[0];
|
int pathLen;
|
||||||
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
|
int txtType;
|
||||||
|
Uint8List pathBytes = Uint8List(0);
|
||||||
|
int channelIdx;
|
||||||
|
if (code == respCodeChannelMsgRecvV3) {
|
||||||
|
reader.skipBytes(1); // Skip SNR
|
||||||
|
final flags = reader.readByte();
|
||||||
|
final hasPath = (flags & 0x01) != 0;
|
||||||
|
reader.skipBytes(1); // Skip reserved byte
|
||||||
|
channelIdx = reader.readByte();
|
||||||
|
pathLen = reader.readInt8();
|
||||||
|
txtType = reader.readByte();
|
||||||
|
if (hasPath && pathLen > 0) {
|
||||||
|
reader.rewind(); // Rewind to read path length again for pathBytes
|
||||||
|
pathBytes = reader.readBytes(pathLen);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channelIdx = reader.readByte();
|
||||||
|
pathLen = reader.readInt8();
|
||||||
|
txtType = reader.readByte();
|
||||||
|
}
|
||||||
|
final timestampRaw = reader.readUInt32LE();
|
||||||
|
|
||||||
|
if (txtType != txtTypePlain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final text = reader.readCString();
|
||||||
|
|
||||||
|
// Extract sender name and actual message from "name: msg" format
|
||||||
|
String senderName = 'Unknown';
|
||||||
|
String actualText = text;
|
||||||
|
|
||||||
|
final colonIndex = text.indexOf(':');
|
||||||
|
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
|
||||||
|
final potentialSender = text.substring(0, colonIndex);
|
||||||
|
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
|
||||||
|
senderName = potentialSender;
|
||||||
|
final offset =
|
||||||
|
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||||
|
? colonIndex + 2
|
||||||
|
: colonIndex + 1;
|
||||||
|
actualText = text.substring(offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
senderKey: null,
|
||||||
|
senderName: senderName,
|
||||||
|
text: decodedText,
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
||||||
|
isOutgoing: false,
|
||||||
|
status: ChannelMessageStatus.sent,
|
||||||
|
pathLength: pathLen,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
channelIndex: channelIdx,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.error('Error parsing channel message frame: $e');
|
||||||
|
// If parsing fails, return null to avoid crashes
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset;
|
|
||||||
Uint8List pathBytes = Uint8List(0);
|
|
||||||
int channelIdx;
|
|
||||||
|
|
||||||
if (code == respCodeChannelMsgRecvV3) {
|
|
||||||
channelIdx = data[4];
|
|
||||||
pathLenOffset = 5;
|
|
||||||
final pathLen = data[pathLenOffset].toSigned(8);
|
|
||||||
var cursor = 6;
|
|
||||||
final hasPathBytesFlag = (data[2] & 0x01) != 0;
|
|
||||||
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
|
|
||||||
final hasValidTxtType =
|
|
||||||
cursor < data.length &&
|
|
||||||
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
|
||||||
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
|
|
||||||
canFitPath) {
|
|
||||||
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
|
|
||||||
cursor += pathLen;
|
|
||||||
}
|
|
||||||
txtTypeOffset = cursor;
|
|
||||||
cursor += 1; // txt_type
|
|
||||||
timestampOffset = cursor;
|
|
||||||
textOffset = cursor + 4;
|
|
||||||
} else {
|
|
||||||
channelIdx = data[1];
|
|
||||||
pathLenOffset = 2;
|
|
||||||
txtTypeOffset = 3;
|
|
||||||
timestampOffset = 4;
|
|
||||||
textOffset = 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.length < textOffset + 1) return null;
|
|
||||||
|
|
||||||
final txtType = data[txtTypeOffset];
|
|
||||||
if (txtType != txtTypePlain) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final pathLen = data[pathLenOffset].toSigned(8);
|
|
||||||
final timestampRaw = readUint32LE(data, timestampOffset);
|
|
||||||
final text = readCString(data, textOffset, data.length - textOffset);
|
|
||||||
|
|
||||||
// Extract sender name and actual message from "name: msg" format
|
|
||||||
String senderName = 'Unknown';
|
|
||||||
String actualText = text;
|
|
||||||
|
|
||||||
final colonIndex = text.indexOf(':');
|
|
||||||
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
|
|
||||||
final potentialSender = text.substring(0, colonIndex);
|
|
||||||
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
|
|
||||||
senderName = potentialSender;
|
|
||||||
final offset =
|
|
||||||
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
|
||||||
? colonIndex + 2
|
|
||||||
: colonIndex + 1;
|
|
||||||
actualText = text.substring(offset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
|
|
||||||
|
|
||||||
return ChannelMessage(
|
|
||||||
senderKey: null,
|
|
||||||
senderName: senderName,
|
|
||||||
text: decodedText,
|
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
|
||||||
isOutgoing: false,
|
|
||||||
status: ChannelMessageStatus.sent,
|
|
||||||
pathLength: pathLen,
|
|
||||||
pathBytes: pathBytes,
|
|
||||||
channelIndex: channelIdx,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static ChannelMessage outgoing(
|
static ChannelMessage outgoing(
|
||||||
|
|||||||
+32
-40
@@ -18,6 +18,7 @@ class Contact {
|
|||||||
final DateTime lastSeen;
|
final DateTime lastSeen;
|
||||||
final DateTime lastMessageAt;
|
final DateTime lastMessageAt;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
final bool wasPulled;
|
||||||
final Uint8List? rawPacket;
|
final Uint8List? rawPacket;
|
||||||
|
|
||||||
Contact({
|
Contact({
|
||||||
@@ -34,6 +35,7 @@ class Contact {
|
|||||||
required this.lastSeen,
|
required this.lastSeen,
|
||||||
DateTime? lastMessageAt,
|
DateTime? lastMessageAt,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
|
this.wasPulled = false,
|
||||||
this.rawPacket,
|
this.rawPacket,
|
||||||
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
||||||
|
|
||||||
@@ -65,7 +67,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({
|
||||||
@@ -108,7 +120,7 @@ class Contact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -130,43 +142,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);
|
||||||
@@ -183,6 +159,12 @@ class Contact {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final pubKey = reader.readBytes(pubKeySize);
|
final pubKey = reader.readBytes(pubKeySize);
|
||||||
|
|
||||||
|
// Guard: reject contacts with zeroed or mostly-zeroed public keys
|
||||||
|
// (indicates corrupt flash storage on the firmware side)
|
||||||
|
final zeroCount = pubKey.where((b) => b == 0).length;
|
||||||
|
if (zeroCount > pubKeySize ~/ 2) return null;
|
||||||
|
|
||||||
final type = reader.readByte();
|
final type = reader.readByte();
|
||||||
final flags = reader.readByte();
|
final flags = reader.readByte();
|
||||||
final pathLen = reader.readByte();
|
final pathLen = reader.readByte();
|
||||||
@@ -192,11 +174,18 @@ class Contact {
|
|||||||
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||||
final name = reader.readCStringGreedy(maxNameSize);
|
final name = reader.readCStringGreedy(maxNameSize);
|
||||||
|
|
||||||
|
// Guard: reject contacts with non-printable names (corrupt flash data)
|
||||||
|
if (name.isNotEmpty &&
|
||||||
|
name.codeUnits.every((c) => c < 0x20 || c == 0xFFFD)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final lastMod = reader.readUInt32LE();
|
final lastMod = reader.readUInt32LE();
|
||||||
|
|
||||||
double? lat, lon;
|
double? lat, lon;
|
||||||
final latRaw = reader.readInt32LE();
|
final latRaw = reader.readInt32LE();
|
||||||
final lonRaw = reader.readInt32LE();
|
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;
|
||||||
@@ -207,7 +196,7 @@ class Contact {
|
|||||||
name: name.isEmpty ? 'Unknown' : name,
|
name: name.isEmpty ? 'Unknown' : name,
|
||||||
type: type,
|
type: type,
|
||||||
flags: flags,
|
flags: flags,
|
||||||
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
|
pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen,
|
||||||
path: pathBytes,
|
path: pathBytes,
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
@@ -227,4 +216,7 @@ class Contact {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => publicKeyHex.hashCode;
|
int get hashCode => publicKeyHex.hashCode;
|
||||||
|
bool get teleBaseEnabled => (flags & contactFlagTeleBase) != 0;
|
||||||
|
bool get teleLocEnabled => (flags & contactFlagTeleLoc) != 0;
|
||||||
|
bool get teleEnvEnabled => (flags & contactFlagTeleEnv) != 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class DeliveryObservation {
|
||||||
|
final String contactKey;
|
||||||
|
final int pathLength;
|
||||||
|
final int messageBytes;
|
||||||
|
final int secondsSinceLastRx;
|
||||||
|
final bool isFlood;
|
||||||
|
final int deliveryMs;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
DeliveryObservation({
|
||||||
|
required this.contactKey,
|
||||||
|
required this.pathLength,
|
||||||
|
required this.messageBytes,
|
||||||
|
required this.secondsSinceLastRx,
|
||||||
|
required this.isFlood,
|
||||||
|
required this.deliveryMs,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'contact_key': contactKey,
|
||||||
|
'path_length': pathLength,
|
||||||
|
'message_bytes': messageBytes,
|
||||||
|
'seconds_since_last_rx': secondsSinceLastRx,
|
||||||
|
'is_flood': isFlood,
|
||||||
|
'delivery_ms': deliveryMs,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DeliveryObservation(
|
||||||
|
contactKey: json['contact_key'] as String,
|
||||||
|
pathLength: json['path_length'] as int,
|
||||||
|
messageBytes: json['message_bytes'] as int,
|
||||||
|
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
|
||||||
|
isFlood: json['is_flood'] as bool,
|
||||||
|
deliveryMs: json['delivery_ms'] as int,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-27
@@ -16,13 +16,14 @@ class Message {
|
|||||||
final String? messageId;
|
final String? messageId;
|
||||||
final int retryCount;
|
final int retryCount;
|
||||||
final int? estimatedTimeoutMs;
|
final int? estimatedTimeoutMs;
|
||||||
final Uint8List? expectedAckHash;
|
final int? expectedAckHash;
|
||||||
final DateTime? sentAt;
|
final DateTime? sentAt;
|
||||||
final DateTime? deliveredAt;
|
final DateTime? deliveredAt;
|
||||||
final int? tripTimeMs;
|
final int? tripTimeMs;
|
||||||
final int? pathLength;
|
final int? pathLength;
|
||||||
final Uint8List pathBytes;
|
final Uint8List pathBytes;
|
||||||
final Map<String, int> reactions;
|
final Map<String, int> reactions;
|
||||||
|
final Map<String, MessageStatus> reactionStatuses;
|
||||||
final Uint8List fourByteRoomContactKey;
|
final Uint8List fourByteRoomContactKey;
|
||||||
|
|
||||||
Message({
|
Message({
|
||||||
@@ -43,9 +44,11 @@ class Message {
|
|||||||
Uint8List? pathBytes,
|
Uint8List? pathBytes,
|
||||||
Uint8List? fourByteRoomContactKey,
|
Uint8List? fourByteRoomContactKey,
|
||||||
Map<String, int>? reactions,
|
Map<String, int>? reactions,
|
||||||
|
Map<String, MessageStatus>? reactionStatuses,
|
||||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||||
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
||||||
reactions = reactions ?? {};
|
reactions = reactions ?? {},
|
||||||
|
reactionStatuses = reactionStatuses ?? {};
|
||||||
|
|
||||||
String get senderKeyHex => pubKeyToHex(senderKey);
|
String get senderKeyHex => pubKeyToHex(senderKey);
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ class Message {
|
|||||||
MessageStatus? status,
|
MessageStatus? status,
|
||||||
int? retryCount,
|
int? retryCount,
|
||||||
int? estimatedTimeoutMs,
|
int? estimatedTimeoutMs,
|
||||||
Uint8List? expectedAckHash,
|
int? expectedAckHash,
|
||||||
DateTime? sentAt,
|
DateTime? sentAt,
|
||||||
DateTime? deliveredAt,
|
DateTime? deliveredAt,
|
||||||
int? tripTimeMs,
|
int? tripTimeMs,
|
||||||
@@ -61,6 +64,7 @@ class Message {
|
|||||||
Uint8List? pathBytes,
|
Uint8List? pathBytes,
|
||||||
bool? isCli,
|
bool? isCli,
|
||||||
Map<String, int>? reactions,
|
Map<String, int>? reactions,
|
||||||
|
Map<String, MessageStatus>? reactionStatuses,
|
||||||
Uint8List? fourByteRoomContactKey,
|
Uint8List? fourByteRoomContactKey,
|
||||||
}) {
|
}) {
|
||||||
return Message(
|
return Message(
|
||||||
@@ -80,38 +84,41 @@ class Message {
|
|||||||
pathLength: pathLength ?? this.pathLength,
|
pathLength: pathLength ?? this.pathLength,
|
||||||
pathBytes: pathBytes ?? this.pathBytes,
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
reactions: reactions ?? this.reactions,
|
reactions: reactions ?? this.reactions,
|
||||||
|
reactionStatuses: reactionStatuses ?? this.reactionStatuses,
|
||||||
fourByteRoomContactKey:
|
fourByteRoomContactKey:
|
||||||
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
|
static Message? fromFrame(Uint8List frame, Uint8List selfPubKey) {
|
||||||
if (data.length < msgTextOffset + 1) return null;
|
if (frame.length < msgTextOffset + 1) return null;
|
||||||
|
final reader = BufferReader(frame);
|
||||||
|
try {
|
||||||
|
final code = reader.readByte();
|
||||||
|
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final code = data[0];
|
final senderKey = reader.readBytes(pubKeySize);
|
||||||
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
final timestampRaw = reader.readInt32LE();
|
||||||
|
final flags = reader.readByte();
|
||||||
|
if ((flags >> 2) != txtTypePlain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final text = reader.readCString();
|
||||||
|
|
||||||
|
return Message(
|
||||||
|
senderKey: senderKey,
|
||||||
|
text: text,
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
||||||
|
isOutgoing: false,
|
||||||
|
isCli: false,
|
||||||
|
status: MessageStatus.delivered,
|
||||||
|
pathBytes: Uint8List(0),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final senderKey = Uint8List.fromList(
|
|
||||||
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize),
|
|
||||||
);
|
|
||||||
final timestampRaw = readUint32LE(data, msgTimestampOffset);
|
|
||||||
final flags = data[msgFlagsOffset];
|
|
||||||
if ((flags >> 2) != txtTypePlain) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final text = readCString(data, msgTextOffset, data.length - msgTextOffset);
|
|
||||||
|
|
||||||
return Message(
|
|
||||||
senderKey: senderKey,
|
|
||||||
text: text,
|
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
|
||||||
isOutgoing: false,
|
|
||||||
isCli: false,
|
|
||||||
status: MessageStatus.delivered,
|
|
||||||
pathBytes: Uint8List(0),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Message outgoing(
|
static Message outgoing(
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
class PathRecord {
|
class PathRecord {
|
||||||
final int hopCount;
|
final int hopCount;
|
||||||
final int tripTimeMs;
|
final int tripTimeMs;
|
||||||
final DateTime timestamp;
|
final DateTime? timestamp;
|
||||||
final bool wasFloodDiscovery;
|
final bool wasFloodDiscovery;
|
||||||
final List<int> pathBytes;
|
final List<int> pathBytes;
|
||||||
final int successCount;
|
final int successCount;
|
||||||
final int failureCount;
|
final int failureCount;
|
||||||
|
final double routeWeight;
|
||||||
|
|
||||||
PathRecord({
|
PathRecord({
|
||||||
required this.hopCount,
|
required this.hopCount,
|
||||||
@@ -15,6 +16,7 @@ class PathRecord {
|
|||||||
required this.pathBytes,
|
required this.pathBytes,
|
||||||
required this.successCount,
|
required this.successCount,
|
||||||
required this.failureCount,
|
required this.failureCount,
|
||||||
|
this.routeWeight = 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get displayText =>
|
String get displayText =>
|
||||||
@@ -24,11 +26,12 @@ class PathRecord {
|
|||||||
return {
|
return {
|
||||||
'hop_count': hopCount,
|
'hop_count': hopCount,
|
||||||
'trip_time_ms': tripTimeMs,
|
'trip_time_ms': tripTimeMs,
|
||||||
'timestamp': timestamp.toIso8601String(),
|
'timestamp': timestamp?.toIso8601String(),
|
||||||
'was_flood': wasFloodDiscovery,
|
'was_flood': wasFloodDiscovery,
|
||||||
'path_bytes': pathBytes,
|
'path_bytes': pathBytes,
|
||||||
'success_count': successCount,
|
'success_count': successCount,
|
||||||
'failure_count': failureCount,
|
'failure_count': failureCount,
|
||||||
|
'route_weight': routeWeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +39,15 @@ class PathRecord {
|
|||||||
return PathRecord(
|
return PathRecord(
|
||||||
hopCount: json['hop_count'] as int,
|
hopCount: json['hop_count'] as int,
|
||||||
tripTimeMs: json['trip_time_ms'] as int,
|
tripTimeMs: json['trip_time_ms'] as int,
|
||||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
timestamp: json['timestamp'] != null
|
||||||
|
? DateTime.parse(json['timestamp'] as String)
|
||||||
|
: null,
|
||||||
wasFloodDiscovery: json['was_flood'] as bool,
|
wasFloodDiscovery: json['was_flood'] as bool,
|
||||||
pathBytes:
|
pathBytes:
|
||||||
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||||
successCount: json['success_count'] as int? ?? 0,
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
failureCount: json['failure_count'] as int? ?? 0,
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
|
routeWeight: (json['route_weight'] as num?)?.toDouble() ?? 1.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'contact.dart';
|
||||||
|
|
||||||
|
const int recentAttemptDiversityWindow = 2;
|
||||||
|
|
||||||
class PathSelection {
|
class PathSelection {
|
||||||
final List<int> pathBytes;
|
final List<int> pathBytes;
|
||||||
final int hopCount;
|
final int hopCount;
|
||||||
@@ -9,3 +15,38 @@ class PathSelection {
|
|||||||
required this.useFlood,
|
required this.useFlood,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PathSelection resolvePathSelection(
|
||||||
|
Contact contact, {
|
||||||
|
PathSelection? selection,
|
||||||
|
bool forceFlood = false,
|
||||||
|
}) {
|
||||||
|
if (contact.pathOverride != null) {
|
||||||
|
if (contact.pathOverride! < 0) {
|
||||||
|
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||||
|
}
|
||||||
|
return PathSelection(
|
||||||
|
pathBytes: contact.pathOverrideBytes ?? Uint8List(0),
|
||||||
|
hopCount: contact.pathOverride!,
|
||||||
|
useFlood: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
||||||
|
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||||
|
return PathSelection(
|
||||||
|
pathBytes: selection.pathBytes,
|
||||||
|
hopCount: selection.hopCount,
|
||||||
|
useFlood: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PathSelection(
|
||||||
|
pathBytes: contact.path,
|
||||||
|
hopCount: contact.pathLength,
|
||||||
|
useFlood: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -310,6 +310,118 @@ class AppSettingsScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (settingsService.settings.autoRouteRotationEnabled) ...[
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
title: Text(context.l10n.appSettings_maxRouteWeight),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(context.l10n.appSettings_maxRouteWeightSubtitle),
|
||||||
|
Slider(
|
||||||
|
value: settingsService.settings.maxRouteWeight,
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
divisions: 9,
|
||||||
|
label: settingsService.settings.maxRouteWeight
|
||||||
|
.round()
|
||||||
|
.toString(),
|
||||||
|
onChanged: (value) =>
|
||||||
|
settingsService.setMaxRouteWeight(value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
title: Text(context.l10n.appSettings_initialRouteWeight),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(context.l10n.appSettings_initialRouteWeightSubtitle),
|
||||||
|
Slider(
|
||||||
|
value: settingsService.settings.initialRouteWeight,
|
||||||
|
min: 0.5,
|
||||||
|
max: 5.0,
|
||||||
|
divisions: 9,
|
||||||
|
label: settingsService.settings.initialRouteWeight
|
||||||
|
.toStringAsFixed(1),
|
||||||
|
onChanged: (value) =>
|
||||||
|
settingsService.setInitialRouteWeight(value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
title: Text(context.l10n.appSettings_routeWeightSuccessIncrement),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context
|
||||||
|
.l10n
|
||||||
|
.appSettings_routeWeightSuccessIncrementSubtitle,
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: settingsService.settings.routeWeightSuccessIncrement,
|
||||||
|
min: 0.1,
|
||||||
|
max: 2.0,
|
||||||
|
divisions: 19,
|
||||||
|
label: settingsService.settings.routeWeightSuccessIncrement
|
||||||
|
.toStringAsFixed(1),
|
||||||
|
onChanged: (value) =>
|
||||||
|
settingsService.setRouteWeightSuccessIncrement(value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
title: Text(context.l10n.appSettings_routeWeightFailureDecrement),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context
|
||||||
|
.l10n
|
||||||
|
.appSettings_routeWeightFailureDecrementSubtitle,
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: settingsService.settings.routeWeightFailureDecrement,
|
||||||
|
min: 0.1,
|
||||||
|
max: 2.0,
|
||||||
|
divisions: 19,
|
||||||
|
label: settingsService.settings.routeWeightFailureDecrement
|
||||||
|
.toStringAsFixed(1),
|
||||||
|
onChanged: (value) =>
|
||||||
|
settingsService.setRouteWeightFailureDecrement(value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
title: Text(context.l10n.appSettings_maxMessageRetries),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(context.l10n.appSettings_maxMessageRetriesSubtitle),
|
||||||
|
Slider(
|
||||||
|
value: settingsService.settings.maxMessageRetries
|
||||||
|
.toDouble(),
|
||||||
|
min: 2,
|
||||||
|
max: 10,
|
||||||
|
divisions: 8,
|
||||||
|
label: settingsService.settings.maxMessageRetries
|
||||||
|
.toString(),
|
||||||
|
onChanged: (value) =>
|
||||||
|
settingsService.setMaxMessageRetries(value.toInt()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -283,66 +283,66 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
if (payload.length < 101) {
|
if (payload.length < 101) {
|
||||||
return 'ADVERT (short)';
|
return 'ADVERT (short)';
|
||||||
}
|
}
|
||||||
var offset = 0;
|
final reader = BufferReader(payload);
|
||||||
final pubKey = _bytesToHex(
|
try {
|
||||||
payload.sublist(offset, offset + 32),
|
final pubKey = _bytesToHex(reader.readBytes(pubKeySize), spaced: false);
|
||||||
spaced: false,
|
|
||||||
);
|
final timestamp = reader.readUInt32LE();
|
||||||
offset += 32;
|
reader.skipBytes(signatureSize);
|
||||||
final timestamp = readUint32LE(payload, offset);
|
final flags = reader.readByte();
|
||||||
offset += 4;
|
final role = _deviceRoleLabel(flags & 0x0F);
|
||||||
offset += 64; // signature
|
final hasLocation = (flags & 0x10) != 0;
|
||||||
final flags = payload[offset++];
|
final hasFeature1 = (flags & 0x20) != 0;
|
||||||
final role = _deviceRoleLabel(flags & 0x0F);
|
final hasFeature2 = (flags & 0x40) != 0;
|
||||||
final hasLocation = (flags & 0x10) != 0;
|
final hasName = (flags & 0x80) != 0;
|
||||||
final hasFeature1 = (flags & 0x20) != 0;
|
String? name;
|
||||||
final hasFeature2 = (flags & 0x40) != 0;
|
double? lat;
|
||||||
final hasName = (flags & 0x80) != 0;
|
double? lon;
|
||||||
String? name;
|
if (hasLocation) {
|
||||||
double? lat;
|
lat = reader.readInt32LE() / 1000000.0;
|
||||||
double? lon;
|
lon = reader.readInt32LE() / 1000000.0;
|
||||||
if (hasLocation && payload.length >= offset + 8) {
|
}
|
||||||
lat = readInt32LE(payload, offset) / 1000000.0;
|
if (hasFeature1) reader.skipBytes(2);
|
||||||
lon = readInt32LE(payload, offset + 4) / 1000000.0;
|
if (hasFeature2) reader.skipBytes(2);
|
||||||
offset += 8;
|
if (hasName) {
|
||||||
|
name = reader.readCStringGreedy(maxNameSize);
|
||||||
|
}
|
||||||
|
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
|
||||||
|
final locPart = (lat != null && lon != null)
|
||||||
|
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
|
||||||
|
: '';
|
||||||
|
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}…';
|
||||||
|
} catch (e) {
|
||||||
|
return 'ADVERT (invalid)';
|
||||||
}
|
}
|
||||||
if (hasFeature1) offset += 2;
|
|
||||||
if (hasFeature2) offset += 2;
|
|
||||||
if (hasName && payload.length > offset) {
|
|
||||||
final rawName = String.fromCharCodes(payload.sublist(offset));
|
|
||||||
final nul = rawName.indexOf('\u0000');
|
|
||||||
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
|
|
||||||
name = name.trim();
|
|
||||||
}
|
|
||||||
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
|
|
||||||
final locPart = (lat != null && lon != null)
|
|
||||||
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
|
|
||||||
: '';
|
|
||||||
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}…';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _decodeControlSummary(Uint8List payload) {
|
String _decodeControlSummary(Uint8List payload) {
|
||||||
if (payload.isEmpty) return 'CONTROL (empty)';
|
final reader = BufferReader(payload);
|
||||||
final flags = payload[0];
|
try {
|
||||||
final subType = flags & 0xF0;
|
final flags = reader.readByte();
|
||||||
if (subType == 0x80) {
|
final subType = flags & 0xF0;
|
||||||
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
if (subType == 0x80) {
|
||||||
final typeFilter = payload[1];
|
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
||||||
final tag = readUint32LE(payload, 2);
|
final typeFilter = reader.readByte();
|
||||||
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
|
final tag = reader.readInt32LE();
|
||||||
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
|
final since = payload.length >= 10 ? reader.readInt32LE() : 0;
|
||||||
|
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
|
||||||
|
}
|
||||||
|
if (subType == 0x90) {
|
||||||
|
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
|
||||||
|
final nodeType = flags & 0x0F;
|
||||||
|
final snrRaw = payload[1];
|
||||||
|
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
|
||||||
|
final snr = snrSigned / 4.0;
|
||||||
|
final tag = reader.readInt32LE();
|
||||||
|
final keyLen = payload.length - 6;
|
||||||
|
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
|
||||||
|
}
|
||||||
|
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
|
||||||
|
} catch (e) {
|
||||||
|
return 'CONTROL (invalid)';
|
||||||
}
|
}
|
||||||
if (subType == 0x90) {
|
|
||||||
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
|
|
||||||
final nodeType = flags & 0x0F;
|
|
||||||
final snrRaw = payload[1];
|
|
||||||
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
|
|
||||||
final snr = snrSigned / 4.0;
|
|
||||||
final tag = readUint32LE(payload, 2);
|
|
||||||
final keyLen = payload.length - 6;
|
|
||||||
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
|
|
||||||
}
|
|
||||||
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _payloadTypeLabel(int payloadType) {
|
String _payloadTypeLabel(int payloadType) {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import 'dart:math' as math;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
import '../helpers/chat_scroll_controller.dart';
|
import '../helpers/chat_scroll_controller.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../helpers/link_handler.dart';
|
import '../helpers/link_handler.dart';
|
||||||
@@ -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,
|
||||||
@@ -311,8 +338,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _showMessagePathInfo(message),
|
onTap: PlatformInfo.isDesktop
|
||||||
|
? null
|
||||||
|
: () => _showMessagePathInfo(message),
|
||||||
onLongPress: () => _showMessageActions(message),
|
onLongPress: () => _showMessageActions(message),
|
||||||
|
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||||
|
? (_) => _showMessageActions(message)
|
||||||
|
: null,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: gifId != null
|
padding: gifId != null
|
||||||
? const EdgeInsets.all(4)
|
? const EdgeInsets.all(4)
|
||||||
@@ -430,7 +462,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Linkify(
|
child: LinkHandler.buildLinkifyText(
|
||||||
|
context: context,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: bodyFontSize * textScale,
|
fontSize: bodyFontSize * textScale,
|
||||||
@@ -440,15 +473,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
options: const LinkifyOptions(
|
|
||||||
humanize: false,
|
|
||||||
defaultToHttps: false,
|
|
||||||
),
|
|
||||||
linkifiers: const [UrlLinkifier()],
|
|
||||||
onOpen: (link) => LinkHandler.handleLinkTap(
|
|
||||||
context,
|
|
||||||
link.url,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!enableTracing && isOutgoing) ...[
|
if (!enableTracing && isOutgoing) ...[
|
||||||
@@ -557,7 +581,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isOutgoing) {
|
if (!isOutgoing && !PlatformInfo.isDesktop) {
|
||||||
return _SwipeReplyBubble(
|
return _SwipeReplyBubble(
|
||||||
maxSwipeOffset: maxSwipeOffset,
|
maxSwipeOffset: maxSwipeOffset,
|
||||||
replySwipeThreshold: replySwipeThreshold,
|
replySwipeThreshold: replySwipeThreshold,
|
||||||
@@ -1112,6 +1136,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
_setReplyingTo(message);
|
_setReplyingTo(message);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (PlatformInfo.isDesktop)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.route),
|
||||||
|
title: Text(context.l10n.chat_path),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_showMessagePathInfo(message);
|
||||||
|
},
|
||||||
|
),
|
||||||
// Can't react to your own messages
|
// Can't react to your own messages
|
||||||
if (!message.isOutgoing)
|
if (!message.isOutgoing)
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|||||||
@@ -40,10 +40,7 @@ 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 = <Contact>[
|
final contacts = connector.allContacts;
|
||||||
...connector.contacts,
|
|
||||||
...connector.discoveredContacts,
|
|
||||||
];
|
|
||||||
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||||
final hasHopDetails = primaryPath.isNotEmpty;
|
final hasHopDetails = primaryPath.isNotEmpty;
|
||||||
final observedLabel = _formatObservedHops(
|
final observedLabel = _formatObservedHops(
|
||||||
@@ -65,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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -367,10 +365,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
: selectedPathTmp;
|
: selectedPathTmp;
|
||||||
|
|
||||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||||
final contacts = <Contact>[
|
final contacts = connector.allContacts;
|
||||||
...connector.contacts,
|
|
||||||
...connector.discoveredContacts,
|
|
||||||
];
|
|
||||||
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||||
|
|
||||||
final points = <LatLng>[];
|
final points = <LatLng>[];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:meshcore_open/storage/channel_message_store.dart';
|
import 'package:meshcore_open/storage/channel_message_store.dart';
|
||||||
|
import 'package:meshcore_open/utils/platform_info.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';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -417,78 +418,96 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
return Card(
|
return Card(
|
||||||
key: ValueKey('channel_${channel.index}'),
|
key: ValueKey('channel_${channel.index}'),
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: ListTile(
|
child: GestureDetector(
|
||||||
dense: true,
|
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||||
minVerticalPadding: 0,
|
? (_) => _showChannelActions(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
context,
|
||||||
visualDensity: const VisualDensity(vertical: -2),
|
connector,
|
||||||
leading: Stack(
|
channelMessageStore,
|
||||||
children: [
|
channel,
|
||||||
CircleAvatar(
|
)
|
||||||
backgroundColor: bgColor,
|
: null,
|
||||||
child: Icon(icon, color: iconColor),
|
child: ListTile(
|
||||||
),
|
dense: true,
|
||||||
if (isCommunityChannel)
|
minVerticalPadding: 0,
|
||||||
Positioned(
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
right: 0,
|
visualDensity: const VisualDensity(vertical: -2),
|
||||||
bottom: 0,
|
leading: Stack(
|
||||||
child: Container(
|
children: [
|
||||||
width: 14,
|
CircleAvatar(
|
||||||
height: 14,
|
backgroundColor: bgColor,
|
||||||
decoration: BoxDecoration(
|
child: Icon(icon, color: iconColor),
|
||||||
color: Colors.purple,
|
),
|
||||||
shape: BoxShape.circle,
|
if (isCommunityChannel)
|
||||||
border: Border.all(
|
Positioned(
|
||||||
color: Theme.of(context).cardColor,
|
right: 0,
|
||||||
width: 2,
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.purple,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.people,
|
||||||
|
size: 8,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.people, size: 8, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
channel.name.isEmpty
|
|
||||||
? context.l10n.channels_channelIndex(channel.index)
|
|
||||||
: channel.name,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (unreadCount > 0) ...[
|
|
||||||
UnreadBadge(count: unreadCount),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
],
|
||||||
if (showDragHandle && dragIndex != null)
|
),
|
||||||
ReorderableDelayedDragStartListener(
|
title: Text(
|
||||||
index: dragIndex,
|
channel.name.isEmpty
|
||||||
child: Icon(
|
? context.l10n.channels_channelIndex(channel.index)
|
||||||
Icons.drag_handle,
|
: channel.name,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (unreadCount > 0) ...[
|
||||||
|
UnreadBadge(count: unreadCount),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
if (showDragHandle && dragIndex != null)
|
||||||
|
ReorderableDelayedDragStartListener(
|
||||||
|
index: dragIndex,
|
||||||
|
child: Icon(
|
||||||
|
Icons.drag_handle,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
onTap: () async {
|
||||||
onTap: () async {
|
connector.markChannelRead(channel.index);
|
||||||
connector.markChannelRead(channel.index);
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
if (context.mounted) {
|
||||||
if (context.mounted) {
|
Navigator.push(
|
||||||
Navigator.push(
|
context,
|
||||||
context,
|
MaterialPageRoute(
|
||||||
MaterialPageRoute(
|
builder: (context) => ChannelChatScreen(channel: channel),
|
||||||
builder: (context) => ChannelChatScreen(channel: channel),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
},
|
||||||
},
|
onLongPress: () => _showChannelActions(
|
||||||
onLongPress: () => _showChannelActions(
|
context,
|
||||||
context,
|
connector,
|
||||||
connector,
|
channelMessageStore,
|
||||||
channelMessageStore,
|
channel,
|
||||||
channel,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+399
-129
@@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
@@ -16,6 +17,7 @@ import '../helpers/reaction_helper.dart';
|
|||||||
import '../widgets/message_status_icon.dart';
|
import '../widgets/message_status_icon.dart';
|
||||||
import '../helpers/chat_scroll_controller.dart';
|
import '../helpers/chat_scroll_controller.dart';
|
||||||
import '../helpers/link_handler.dart';
|
import '../helpers/link_handler.dart';
|
||||||
|
import '../helpers/path_helper.dart';
|
||||||
import '../helpers/utf8_length_limiter.dart';
|
import '../helpers/utf8_length_limiter.dart';
|
||||||
import '../models/channel_message.dart';
|
import '../models/channel_message.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -36,6 +38,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 +109,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 +145,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 +176,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 +184,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 +222,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 +230,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 +247,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 +357,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 +377,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 +402,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,15 +423,18 @@ 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),
|
||||||
|
onRetryReaction: (msg, emoji) =>
|
||||||
|
_sendReaction(msg, contact, emoji),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -457,7 +569,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 +766,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 +775,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 +834,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 +864,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;
|
||||||
@@ -779,7 +893,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatRelativeTime(DateTime time) {
|
String _formatRelativeTime(DateTime? time) {
|
||||||
|
if (time == null) return '—';
|
||||||
final diff = DateTime.now().difference(time);
|
final diff = DateTime.now().difference(time);
|
||||||
if (diff.inSeconds < 60) return context.l10n.time_justNow;
|
if (diff.inSeconds < 60) return context.l10n.time_justNow;
|
||||||
if (diff.inMinutes < 60) {
|
if (diff.inMinutes < 60) {
|
||||||
@@ -800,15 +915,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final formattedPath = pathBytes
|
final connector = context.read<MeshCoreConnector>();
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
final allContacts = connector.allContacts;
|
||||||
.join(',');
|
|
||||||
|
final formattedPath = PathHelper.formatPathHex(pathBytes);
|
||||||
|
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(context.l10n.chat_fullPath),
|
title: Text(context.l10n.chat_fullPath),
|
||||||
content: SelectableText(formattedPath),
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SelectableText(formattedPath),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SelectableText(
|
||||||
|
resolvedNames,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () => Navigator.push(
|
||||||
@@ -817,7 +948,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 +964,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,59 +1032,127 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
void _showContactInfo(BuildContext context) {
|
void _showContactInfo(BuildContext context) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
final contact = _resolveContact(connector);
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Consumer<MeshCoreConnector>(
|
builder: (context) => AlertDialog(
|
||||||
builder: (context, connector, _) {
|
title: SelectableText(contact.name),
|
||||||
final contact = _resolveContact(connector);
|
content: SingleChildScrollView(
|
||||||
final smazEnabled = connector.isContactSmazEnabled(
|
child: Column(
|
||||||
contact.publicKeyHex,
|
mainAxisSize: MainAxisSize.min,
|
||||||
);
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
return AlertDialog(
|
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
|
||||||
title: Text(contact.name),
|
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
|
||||||
content: SingleChildScrollView(
|
_buildInfoRow(
|
||||||
child: Column(
|
context.l10n.contact_lastSeen,
|
||||||
mainAxisSize: MainAxisSize.min,
|
_formatContactLastMessage(contact.lastMessageAt),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
|
|
||||||
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
|
|
||||||
if (contact.hasLocation)
|
|
||||||
_buildInfoRow(
|
|
||||||
context.l10n.chat_location,
|
|
||||||
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
|
||||||
),
|
|
||||||
_buildInfoRow(
|
|
||||||
context.l10n.chat_publicKey,
|
|
||||||
'${contact.publicKeyHex.substring(0, 16)}...',
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(context.l10n.channels_smazCompression),
|
|
||||||
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
|
|
||||||
value: smazEnabled,
|
|
||||||
onChanged: (value) {
|
|
||||||
connector.setContactSmazEnabled(
|
|
||||||
contact.publicKeyHex,
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(context.l10n.common_close),
|
|
||||||
),
|
),
|
||||||
|
if (contact.hasLocation)
|
||||||
|
_buildInfoRow(
|
||||||
|
context.l10n.chat_location,
|
||||||
|
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||||
|
),
|
||||||
|
_buildInfoRow(context.l10n.chat_publicKey, contact.publicKeyHex),
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(context.l10n.common_close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showContactSettings(BuildContext context) {
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
||||||
|
final contact = widget.contact;
|
||||||
|
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
|
||||||
|
bool teleBaseEnabled = contact.teleBaseEnabled;
|
||||||
|
bool teleLocEnabled = contact.teleLocEnabled;
|
||||||
|
bool teleEnvEnabled = contact.teleEnvEnabled;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: Text(context.l10n.contact_settings),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (contact.hasLocation) ...[
|
||||||
|
_buildInfoRow(
|
||||||
|
context.l10n.chat_location,
|
||||||
|
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
],
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(context.l10n.channels_smazCompression),
|
||||||
|
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
|
||||||
|
value: smazEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
connector.setContactSmazEnabled(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
setDialogState(() => smazEnabled = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(context.l10n.contact_teleBase),
|
||||||
|
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
|
||||||
|
value: teleBaseEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => teleBaseEnabled = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(context.l10n.contact_teleLoc),
|
||||||
|
subtitle: Text(context.l10n.contact_teleLocSubtitle),
|
||||||
|
value: teleLocEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => teleLocEnabled = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(context.l10n.contact_teleEnv),
|
||||||
|
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
|
||||||
|
value: teleEnvEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => teleEnvEnabled = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
connector.setContactFlags(
|
||||||
|
contact,
|
||||||
|
teleBase: teleBaseEnabled,
|
||||||
|
teleLoc: teleLocEnabled,
|
||||||
|
teleEnv: teleEnvEnabled,
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.common_close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -957,12 +1167,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 +1216,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 +1235,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 +1251,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 +1275,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,
|
||||||
@@ -1085,6 +1316,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_showEmojiPicker(message, contact);
|
_showEmojiPicker(message, contact);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (PlatformInfo.isDesktop)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.route),
|
||||||
|
title: Text(context.l10n.chat_path),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_openMessagePath(message, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: Text(context.l10n.common_copy),
|
title: Text(context.l10n.common_copy),
|
||||||
@@ -1110,7 +1350,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 +1389,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 +1415,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 +1425,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,6 +1435,7 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
final bool isRoomServer;
|
final bool isRoomServer;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onLongPress;
|
final VoidCallback? onLongPress;
|
||||||
|
final void Function(Message message, String emoji)? onRetryReaction;
|
||||||
final double textScale;
|
final double textScale;
|
||||||
|
|
||||||
const _MessageBubble({
|
const _MessageBubble({
|
||||||
@@ -1202,6 +1445,7 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
required this.textScale,
|
required this.textScale,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
|
this.onRetryReaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1235,8 +1479,11 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
: CrossAxisAlignment.start,
|
: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onTap,
|
onTap: PlatformInfo.isDesktop ? null : onTap,
|
||||||
onLongPress: onLongPress,
|
onLongPress: onLongPress,
|
||||||
|
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||||
|
? (_) => onLongPress?.call()
|
||||||
|
: null,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: isOutgoing
|
mainAxisAlignment: isOutgoing
|
||||||
? MainAxisAlignment.end
|
? MainAxisAlignment.end
|
||||||
@@ -1353,7 +1600,8 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Linkify(
|
child: LinkHandler.buildLinkifyText(
|
||||||
|
context: context,
|
||||||
text: messageText,
|
text: messageText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor,
|
color: textColor,
|
||||||
@@ -1364,15 +1612,6 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
fontSize: bodyFontSize * textScale,
|
fontSize: bodyFontSize * textScale,
|
||||||
),
|
),
|
||||||
options: const LinkifyOptions(
|
|
||||||
humanize: false,
|
|
||||||
defaultToHttps: false,
|
|
||||||
),
|
|
||||||
linkifiers: const [UrlLinkifier()],
|
|
||||||
onOpen: (link) => LinkHandler.handleLinkTap(
|
|
||||||
context,
|
|
||||||
link.url,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!enableTracing && isOutgoing) ...[
|
if (!enableTracing && isOutgoing) ...[
|
||||||
@@ -1562,33 +1801,64 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
children: message.reactions.entries.map((entry) {
|
children: message.reactions.entries.map((entry) {
|
||||||
final emoji = entry.key;
|
final emoji = entry.key;
|
||||||
final count = entry.value;
|
final count = entry.value;
|
||||||
|
final status = message.reactionStatuses[emoji];
|
||||||
|
final isPending =
|
||||||
|
status == MessageStatus.pending || status == MessageStatus.sent;
|
||||||
|
final isFailed = status == MessageStatus.failed;
|
||||||
|
|
||||||
return Container(
|
return GestureDetector(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
onTap: isFailed && onRetryReaction != null
|
||||||
decoration: BoxDecoration(
|
? () => onRetryReaction!(message, emoji)
|
||||||
color: colorScheme.secondaryContainer,
|
: null,
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Opacity(
|
||||||
border: Border.all(
|
opacity: isPending ? 0.5 : 1.0,
|
||||||
color: colorScheme.outline.withValues(alpha: 0.3),
|
child: Container(
|
||||||
width: 1,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
),
|
color: isFailed
|
||||||
child: Row(
|
? colorScheme.errorContainer
|
||||||
mainAxisSize: MainAxisSize.min,
|
: colorScheme.secondaryContainer,
|
||||||
children: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
border: Border.all(
|
||||||
if (count > 1) ...[
|
color: isFailed
|
||||||
const SizedBox(width: 4),
|
? colorScheme.error
|
||||||
Text(
|
: colorScheme.outline.withValues(alpha: 0.3),
|
||||||
'$count',
|
width: 1,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSecondaryContainer,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||||
|
if (count > 1) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'$count',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isPending) ...[
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
SizedBox(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isFailed) ...[
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Icon(Icons.replay, size: 10, color: colorScheme.error),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ 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/utils/platform_info.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';
|
||||||
|
|
||||||
@@ -66,6 +68,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
.contactsSearchText;
|
.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
|
@override
|
||||||
@@ -1231,7 +1240,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: () {
|
||||||
@@ -1239,10 +1248,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1267,10 +1278,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1312,7 +1325,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1340,7 +1354,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(
|
||||||
@@ -1426,66 +1443,77 @@ class _ContactTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return GestureDetector(
|
||||||
leading: CircleAvatar(
|
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
|
||||||
backgroundColor: _getTypeColor(contact.type),
|
child: ListTile(
|
||||||
child: _buildContactAvatar(contact),
|
leading: CircleAvatar(
|
||||||
),
|
backgroundColor: _getTypeColor(contact.type),
|
||||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
child: _buildContactAvatar(contact),
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(contact.pathLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
Text(
|
|
||||||
contact.shortPubKeyHex,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Clamp text scaling in trailing section to prevent overflow while
|
|
||||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
|
||||||
trailing: MediaQuery(
|
|
||||||
data: MediaQuery.of(context).copyWith(
|
|
||||||
textScaler: TextScaler.linear(
|
|
||||||
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: SizedBox(
|
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
width: 120,
|
subtitle: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
Text(
|
||||||
children: [
|
contact.pathLabel,
|
||||||
if (unreadCount > 0) ...[
|
maxLines: 1,
|
||||||
UnreadBadge(count: unreadCount),
|
overflow: TextOverflow.ellipsis,
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
],
|
Text(
|
||||||
Text(
|
contact.shortPubKeyHex,
|
||||||
_formatLastSeen(context, lastSeen),
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: const TextStyle(fontSize: 12),
|
||||||
textAlign: TextAlign.right,
|
),
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
],
|
||||||
),
|
),
|
||||||
Row(
|
// Clamp text scaling in trailing section to prevent overflow while
|
||||||
mainAxisSize: MainAxisSize.min,
|
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||||
children: [
|
trailing: MediaQuery(
|
||||||
if (isFavorite)
|
data: MediaQuery.of(context).copyWith(
|
||||||
Icon(Icons.star, size: 14, color: Colors.amber[700]),
|
textScaler: TextScaler.linear(
|
||||||
if (isFavorite && contact.hasLocation)
|
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
||||||
const SizedBox(width: 2),
|
),
|
||||||
if (contact.hasLocation)
|
),
|
||||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
child: SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (unreadCount > 0) ...[
|
||||||
|
UnreadBadge(count: unreadCount),
|
||||||
|
const SizedBox(height: 4),
|
||||||
],
|
],
|
||||||
),
|
Text(
|
||||||
],
|
_formatLastSeen(context, lastSeen),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (isFavorite)
|
||||||
|
Icon(Icons.star, size: 14, color: Colors.amber[700]),
|
||||||
|
if (isFavorite && contact.hasLocation)
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
if (contact.hasLocation)
|
||||||
|
Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
),
|
),
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../connector/meshcore_protocol.dart';
|
|||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../utils/contact_search.dart';
|
import '../utils/contact_search.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
import '../widgets/app_bar.dart';
|
import '../widgets/app_bar.dart';
|
||||||
import '../widgets/list_filter_widget.dart';
|
import '../widgets/list_filter_widget.dart';
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
itemCount: filteredAndSorted.length,
|
itemCount: filteredAndSorted.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final contact = filteredAndSorted[index];
|
final contact = filteredAndSorted[index];
|
||||||
return ListTile(
|
final tile = ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: _getTypeColor(contact.type),
|
backgroundColor: _getTypeColor(contact.type),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -120,6 +121,14 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
onLongPress: () =>
|
onLongPress: () =>
|
||||||
_showContactContextMenu(contact, connector),
|
_showContactContextMenu(contact, connector),
|
||||||
);
|
);
|
||||||
|
if (PlatformInfo.isDesktop) {
|
||||||
|
return GestureDetector(
|
||||||
|
onSecondaryTapUp: (_) =>
|
||||||
|
_showContactContextMenu(contact, connector),
|
||||||
|
child: tile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tile;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+55
-35
@@ -137,10 +137,7 @@ 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 allContacts = <Contact>[
|
final allContacts = connector.allContacts;
|
||||||
...connector.contacts,
|
|
||||||
...connector.discoveredContacts.where((c) => !c.isActive),
|
|
||||||
];
|
|
||||||
|
|
||||||
final contacts = settings.mapShowDiscoveryContacts
|
final contacts = settings.mapShowDiscoveryContacts
|
||||||
? allContacts
|
? allContacts
|
||||||
@@ -179,20 +176,13 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
|
|
||||||
// Filter by location
|
// Filter by location
|
||||||
final contactsWithLocation = filteredByKeyPrefix.where((c) {
|
final contactsWithLocation = filteredByKeyPrefix.where((c) {
|
||||||
if (!c.hasLocation) {
|
return c.hasLocation;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return _checkLocationPlausibility(c.latitude!, c.longitude!);
|
|
||||||
}).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 = allContacts
|
final allContactsWithLocation = allContacts
|
||||||
.where(
|
.where((c) => c.hasLocation)
|
||||||
(c) =>
|
|
||||||
c.hasLocation &&
|
|
||||||
_checkLocationPlausibility(c.latitude!, c.longitude!),
|
|
||||||
)
|
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Compute guessed locations with caching
|
// Compute guessed locations with caching
|
||||||
@@ -627,19 +617,6 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: for any last-hop byte with no GPS repeater, average the
|
|
||||||
// positions of contacts with known GPS that share the same last hop.
|
|
||||||
// Those contacts are all adjacent to the same unknown repeater, so their
|
|
||||||
// centroid is a reasonable proxy for its location.
|
|
||||||
for (final byte in lastHopBytes) {
|
|
||||||
if (repeaterByHash.containsKey(byte)) continue;
|
|
||||||
for (final c in withLocation) {
|
|
||||||
if (c.path.isNotEmpty && c.path.last == byte) {
|
|
||||||
anchorSet.add(LatLng(c.latitude!, c.longitude!));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter anchors that are geometrically inconsistent with radio range.
|
// Filter anchors that are geometrically inconsistent with radio range.
|
||||||
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
||||||
// range of the same node, so isolated outliers are removed.
|
// range of the same node, so isolated outliers are removed.
|
||||||
@@ -651,15 +628,12 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
|
|
||||||
final LatLng position;
|
final LatLng position;
|
||||||
if (anchors.length == 1) {
|
if (anchors.length == 1) {
|
||||||
// Offset single-anchor guesses so they don't overlap the repeater marker.
|
// Spread single-anchor guesses around the anchor so they remain visible.
|
||||||
// Use the contact's public key byte as a deterministic angle seed.
|
position = _offsetGuessedPosition(
|
||||||
const offsetDeg = 0.003; // ~330 m at the equator
|
anchors[0],
|
||||||
final angle = (contact.publicKey[1] / 255.0) * 2 * pi;
|
contact,
|
||||||
position = LatLng(
|
radiusMeters: 330,
|
||||||
anchors[0].latitude + offsetDeg * cos(angle),
|
|
||||||
anchors[0].longitude + offsetDeg * sin(angle),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!_checkLocationPlausibility(
|
if (!_checkLocationPlausibility(
|
||||||
position.latitude,
|
position.latitude,
|
||||||
position.longitude,
|
position.longitude,
|
||||||
@@ -672,7 +646,11 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
lat += a.latitude;
|
lat += a.latitude;
|
||||||
lon += a.longitude;
|
lon += a.longitude;
|
||||||
}
|
}
|
||||||
position = LatLng(lat / anchors.length, lon / anchors.length);
|
position = _offsetGuessedPosition(
|
||||||
|
LatLng(lat / anchors.length, lon / anchors.length),
|
||||||
|
contact,
|
||||||
|
radiusMeters: anchors.length >= 3 ? 80 : 120,
|
||||||
|
);
|
||||||
if (!_checkLocationPlausibility(
|
if (!_checkLocationPlausibility(
|
||||||
position.latitude,
|
position.latitude,
|
||||||
position.longitude,
|
position.longitude,
|
||||||
@@ -692,6 +670,31 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LatLng _offsetGuessedPosition(
|
||||||
|
LatLng anchor,
|
||||||
|
Contact contact, {
|
||||||
|
required double radiusMeters,
|
||||||
|
}) {
|
||||||
|
final seed = _guessSeed(contact.publicKey);
|
||||||
|
final angle = ((seed & 0xFFFF) / 0x10000) * 2 * pi;
|
||||||
|
final latOffsetDeg = (radiusMeters / 111320.0) * cos(angle);
|
||||||
|
final lonScale = max(cos(anchor.latitude * pi / 180.0).abs(), 0.2);
|
||||||
|
final lonOffsetDeg = (radiusMeters / (111320.0 * lonScale)) * sin(angle);
|
||||||
|
return LatLng(
|
||||||
|
anchor.latitude + latOffsetDeg,
|
||||||
|
anchor.longitude + lonOffsetDeg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _guessSeed(Uint8List publicKey) {
|
||||||
|
var seed = 0x811C9DC5;
|
||||||
|
for (final byte in publicKey) {
|
||||||
|
seed ^= byte;
|
||||||
|
seed = (seed * 0x01000193) & 0x7FFFFFFF;
|
||||||
|
}
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
|
||||||
/// Estimates the free-space maximum LoRa range in km from the connected
|
/// Estimates the free-space maximum LoRa range in km from the connected
|
||||||
/// device's current radio parameters. Returns null if parameters are unknown.
|
/// device's current radio parameters. Returns null if parameters are unknown.
|
||||||
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
||||||
@@ -1509,6 +1512,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),
|
||||||
|
|||||||
@@ -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,10 +142,7 @@ 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 = <Contact>[
|
final contacts = connector.allContacts;
|
||||||
...connector.contacts,
|
|
||||||
...connector.discoveredContacts,
|
|
||||||
];
|
|
||||||
try {
|
try {
|
||||||
final neighborCount = buffer.readUInt16LE();
|
final neighborCount = buffer.readUInt16LE();
|
||||||
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
||||||
@@ -166,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -158,21 +159,16 @@ 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 = buildPath(pathTmp);
|
|
||||||
} else {
|
|
||||||
path = pathTmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
appLogger.info(
|
appLogger.info(
|
||||||
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
||||||
tag: 'PathTraceMapScreen',
|
tag: 'PathTraceMapScreen',
|
||||||
|
noNotify: !mounted,
|
||||||
);
|
);
|
||||||
|
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
@@ -263,10 +259,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<int, Contact> pathContacts = {};
|
Map<int, Contact> pathContacts = {};
|
||||||
final contacts = <Contact>[
|
final contacts = connector.allContacts;
|
||||||
...connector.contacts,
|
|
||||||
...connector.discoveredContacts,
|
|
||||||
];
|
|
||||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||||
for (var repeaterData in pathData) {
|
for (var repeaterData in pathData) {
|
||||||
if (listEquals(
|
if (listEquals(
|
||||||
@@ -312,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 &&
|
||||||
@@ -339,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,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!));
|
||||||
@@ -361,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(
|
||||||
@@ -451,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 &&
|
||||||
@@ -480,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,
|
||||||
@@ -532,6 +573,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
hopLastLast = hopLast;
|
||||||
|
hopLast = hop;
|
||||||
}
|
}
|
||||||
|
|
||||||
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
||||||
@@ -581,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,
|
||||||
@@ -719,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,
|
||||||
@@ -757,6 +801,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
markers: _buildHopMarkers(
|
markers: _buildHopMarkers(
|
||||||
_traceData!.pathData,
|
_traceData!.pathData,
|
||||||
showLabels: _showNodeLabels,
|
showLabels: _showNodeLabels,
|
||||||
|
target: target,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -77,11 +77,22 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTextMessageResponse(Uint8List frame) {
|
void _handleTextMessageResponse(Uint8List frame) {
|
||||||
|
|||||||
@@ -205,8 +205,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) =>
|
builder: (context) => TelemetryScreen(contact: repeater),
|
||||||
TelemetryScreen(repeater: repeater, password: password),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,11 +129,22 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||||
|
|||||||
@@ -91,11 +91,22 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTextMessageResponse(Uint8List frame) {
|
void _handleTextMessageResponse(Uint8List frame) {
|
||||||
|
|||||||
@@ -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,136 @@ 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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../services/app_settings_service.dart';
|
||||||
import '../utils/platform_info.dart';
|
import '../utils/platform_info.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
@@ -27,8 +28,14 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_hostController = TextEditingController();
|
_hostController = TextEditingController(
|
||||||
_portController = TextEditingController(text: '5000');
|
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>();
|
_connector = context.read<MeshCoreConnector>();
|
||||||
|
|
||||||
_connectionListener = () {
|
_connectionListener = () {
|
||||||
@@ -39,6 +46,12 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
if (_connector.state == MeshCoreConnectionState.connected &&
|
if (_connector.state == MeshCoreConnectionState.connected &&
|
||||||
_connector.isTcpTransportConnected &&
|
_connector.isTcpTransportConnected &&
|
||||||
!_navigatedToContacts) {
|
!_navigatedToContacts) {
|
||||||
|
context.read<AppSettingsService>().setTcpServerAddress(
|
||||||
|
_hostController.text,
|
||||||
|
);
|
||||||
|
context.read<AppSettingsService>().setTcpServerPort(
|
||||||
|
int.tryParse(_portController.text) ?? 0,
|
||||||
|
);
|
||||||
_navigatedToContacts = true;
|
_navigatedToContacts = true;
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
|
|||||||
@@ -10,30 +10,22 @@ import '../connector/meshcore_connector.dart';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import '../widgets/path_management_dialog.dart';
|
import '../widgets/path_management_dialog.dart';
|
||||||
import '../helpers/cayenne_lpp.dart';
|
import '../helpers/cayenne_lpp.dart';
|
||||||
import '../utils/battery_utils.dart';
|
import '../utils/battery_utils.dart';
|
||||||
|
|
||||||
class TelemetryScreen extends StatefulWidget {
|
class TelemetryScreen extends StatefulWidget {
|
||||||
final Contact repeater;
|
final Contact contact;
|
||||||
final String password;
|
|
||||||
|
|
||||||
const TelemetryScreen({
|
const TelemetryScreen({super.key, required this.contact});
|
||||||
super.key,
|
|
||||||
required this.repeater,
|
|
||||||
required this.password,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TelemetryScreen> createState() => _TelemetryScreenState();
|
State<TelemetryScreen> createState() => _TelemetryScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TelemetryScreenState extends State<TelemetryScreen> {
|
class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||||
static const int _statusPayloadOffset = 8;
|
int _tagData = 0;
|
||||||
static const int _statusStatsSize = 52;
|
|
||||||
static const int _statusResponseBytes =
|
|
||||||
_statusPayloadOffset + _statusStatsSize;
|
|
||||||
Uint8List _tagData = Uint8List(4);
|
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
@@ -44,6 +36,26 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
PathSelection? _pendingStatusSelection;
|
PathSelection? _pendingStatusSelection;
|
||||||
List<Map<String, dynamic>>? _parsedTelemetry;
|
List<Map<String, dynamic>>? _parsedTelemetry;
|
||||||
|
|
||||||
|
int _tripTime = 0;
|
||||||
|
|
||||||
|
int _resolveContactIndex = -1;
|
||||||
|
|
||||||
|
Contact _resolveContact(MeshCoreConnector connector) {
|
||||||
|
if (_resolveContactIndex >= 0 &&
|
||||||
|
_resolveContactIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveContactIndex].publicKeyHex ==
|
||||||
|
widget.contact.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveContactIndex];
|
||||||
|
}
|
||||||
|
_resolveContactIndex = connector.contacts.indexWhere(
|
||||||
|
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
||||||
|
);
|
||||||
|
if (_resolveContactIndex == -1) {
|
||||||
|
return widget.contact;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveContactIndex];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -60,27 +72,62 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
// Listen for incoming text messages from the repeater
|
// Listen for incoming text messages from the repeater
|
||||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||||
if (frame.isEmpty) return;
|
if (frame.isEmpty) return;
|
||||||
|
final reader = BufferReader(frame);
|
||||||
|
try {
|
||||||
|
final cmd = reader.readByte();
|
||||||
|
if (cmd == respCodeSent) {
|
||||||
|
reader.skipBytes(1); // Skip the reserved byte
|
||||||
|
_tagData = reader.readUInt32LE();
|
||||||
|
_tripTime = reader.readUInt32LE();
|
||||||
|
_statusTimeout?.cancel();
|
||||||
|
_statusTimeout = Timer(Duration(milliseconds: _tripTime), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_isLoaded = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.telemetry_requestTimeout),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_recordTelemetryResult(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (frame[0] == respCodeSent) {
|
// Check if it's a binary response
|
||||||
_tagData = frame.sublist(2, 6);
|
if (cmd == pushCodeBinaryResponse) {
|
||||||
}
|
if (!mounted) return;
|
||||||
|
reader.skipBytes(1); // Skip the reserved byte
|
||||||
|
if (reader.readUInt32LE() != _tagData) return;
|
||||||
|
_handleTelemetryResponse(reader.readRemainingBytes());
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a binary response
|
// Check if it's a telemetry response (for chat contacts)
|
||||||
if (frame[0] == pushCodeBinaryResponse &&
|
if (cmd == pushCodeTelemetryResponse) {
|
||||||
listEquals(frame.sublist(2, 6), _tagData)) {
|
reader.skipBytes(1); // Skip the reserved byte
|
||||||
if (!mounted) return;
|
final pubkey = reader.readBytes(6);
|
||||||
_handleStatusResponse(frame.sublist(6));
|
if (!mounted) return;
|
||||||
|
if (!listEquals(widget.contact.publicKey.sublist(0, 6), pubkey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_handleTelemetryResponse(reader.readRemainingBytes());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.error('Error parsing incoming frame: $e');
|
||||||
|
// If parsing fails, ignore the frame
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleStatusResponse(Uint8List frame) {
|
void _handleTelemetryResponse(Uint8List frame) {
|
||||||
final parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
final parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
||||||
final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry);
|
final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry);
|
||||||
if (batteryMv != null) {
|
if (batteryMv != null) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
connector.updateRepeaterBatterySnapshot(
|
connector.updateRepeaterBatterySnapshot(
|
||||||
widget.repeater.publicKeyHex,
|
widget.contact.publicKeyHex,
|
||||||
batteryMv,
|
batteryMv,
|
||||||
source: 'telemetry',
|
source: 'telemetry',
|
||||||
);
|
);
|
||||||
@@ -105,13 +152,6 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
|
||||||
return connector.contacts.firstWhere(
|
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadTelemetry() async {
|
Future<void> _loadTelemetry() async {
|
||||||
if (_commandService == null) return;
|
if (_commandService == null) return;
|
||||||
|
|
||||||
@@ -121,41 +161,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final repeater = _resolveRepeater(connector);
|
final selection = await connector.preparePathForContactSend(
|
||||||
final selection = await connector.preparePathForContactSend(repeater);
|
_resolveContact(connector),
|
||||||
|
);
|
||||||
_pendingStatusSelection = selection;
|
_pendingStatusSelection = selection;
|
||||||
final frame = buildSendBinaryReq(
|
Uint8List frame;
|
||||||
repeater.publicKey,
|
if (widget.contact.type != advTypeChat) {
|
||||||
payload: Uint8List.fromList([reqTypeGetTelemetry]),
|
frame = buildSendBinaryReq(
|
||||||
);
|
widget.contact.publicKey,
|
||||||
await connector.sendFrame(frame);
|
payload: Uint8List.fromList([reqTypeGetTelemetry]),
|
||||||
|
|
||||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
|
||||||
var messageBytes = frame.length >= _statusResponseBytes
|
|
||||||
? frame.length
|
|
||||||
: _statusResponseBytes;
|
|
||||||
if (messageBytes < maxFrameSize) {
|
|
||||||
messageBytes = maxFrameSize;
|
|
||||||
}
|
|
||||||
final timeoutMs = connector.calculateTimeout(
|
|
||||||
pathLength: pathLengthValue,
|
|
||||||
messageBytes: messageBytes,
|
|
||||||
);
|
|
||||||
_statusTimeout?.cancel();
|
|
||||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
_isLoaded = false;
|
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(context.l10n.telemetry_requestTimeout),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
_recordStatusResult(false);
|
} else {
|
||||||
});
|
frame = buildSendTelemetryReq(widget.contact.publicKey);
|
||||||
|
}
|
||||||
|
await connector.sendFrame(frame);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -173,12 +192,16 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _recordStatusResult(bool success) {
|
void _recordTelemetryResult(bool success) {
|
||||||
final selection = _pendingStatusSelection;
|
final selection = _pendingStatusSelection;
|
||||||
if (selection == null) return;
|
if (selection == null) return;
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final repeater = _resolveRepeater(connector);
|
connector.recordRepeaterPathResult(
|
||||||
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
widget.contact,
|
||||||
|
selection,
|
||||||
|
success,
|
||||||
|
null,
|
||||||
|
);
|
||||||
_pendingStatusSelection = null;
|
_pendingStatusSelection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +219,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final settings = context.watch<AppSettingsService>().settings;
|
final settings = context.watch<AppSettingsService>().settings;
|
||||||
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
|
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
|
||||||
final repeater = _resolveRepeater(connector);
|
final isFloodMode = widget.contact.pathOverride == -1;
|
||||||
final isFloodMode = repeater.pathOverride == -1;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -210,7 +232,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
repeater.name,
|
widget.contact.name,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
@@ -225,9 +247,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
tooltip: l10n.repeater_routingMode,
|
tooltip: l10n.repeater_routingMode,
|
||||||
onSelected: (mode) async {
|
onSelected: (mode) async {
|
||||||
if (mode == 'flood') {
|
if (mode == 'flood') {
|
||||||
await connector.setPathOverride(repeater, pathLen: -1);
|
await connector.setPathOverride(widget.contact, pathLen: -1);
|
||||||
} else {
|
} else {
|
||||||
await connector.setPathOverride(repeater, pathLen: null);
|
await connector.setPathOverride(widget.contact, pathLen: null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
@@ -283,7 +305,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
icon: const Icon(Icons.timeline),
|
icon: const Icon(Icons.timeline),
|
||||||
tooltip: l10n.repeater_pathManagement,
|
tooltip: l10n.repeater_pathManagement,
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
PathManagementDialog.show(context, contact: repeater),
|
PathManagementDialog.show(context, contact: widget.contact),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
@@ -437,7 +459,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final batteryMv =
|
final batteryMv =
|
||||||
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
|
connector.getRepeaterBatteryMillivolts(widget.contact.publicKeyHex) ??
|
||||||
(telemetryVolts == null ? null : (telemetryVolts * 1000).round());
|
(telemetryVolts == null ? null : (telemetryVolts * 1000).round());
|
||||||
if (batteryMv == null) return l10n.common_notAvailable;
|
if (batteryMv == null) return l10n.common_notAvailable;
|
||||||
final chemistry = _batteryChemistry();
|
final chemistry = _batteryChemistry();
|
||||||
@@ -449,7 +471,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
String _batteryChemistry() {
|
String _batteryChemistry() {
|
||||||
final settingsService = context.read<AppSettingsService>();
|
final settingsService = context.read<AppSettingsService>();
|
||||||
return settingsService.batteryChemistryForRepeater(
|
return settingsService.batteryChemistryForRepeater(
|
||||||
widget.repeater.publicKeyHex,
|
widget.contact.publicKeyHex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
if (!noNotify) {
|
||||||
|
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() {
|
||||||
|
|||||||
@@ -120,6 +120,30 @@ class AppSettingsService extends ChangeNotifier {
|
|||||||
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
|
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setMaxRouteWeight(double value) async {
|
||||||
|
await updateSettings(_settings.copyWith(maxRouteWeight: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setInitialRouteWeight(double value) async {
|
||||||
|
await updateSettings(_settings.copyWith(initialRouteWeight: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRouteWeightSuccessIncrement(double value) async {
|
||||||
|
await updateSettings(
|
||||||
|
_settings.copyWith(routeWeightSuccessIncrement: value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRouteWeightFailureDecrement(double value) async {
|
||||||
|
await updateSettings(
|
||||||
|
_settings.copyWith(routeWeightFailureDecrement: value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMaxMessageRetries(int value) async {
|
||||||
|
await updateSettings(_settings.copyWith(maxMessageRetries: value));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setThemeMode(String value) async {
|
Future<void> setThemeMode(String value) async {
|
||||||
await updateSettings(_settings.copyWith(themeMode: value));
|
await updateSettings(_settings.copyWith(themeMode: value));
|
||||||
}
|
}
|
||||||
@@ -182,4 +206,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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'dart:ui';
|
|||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../helpers/reaction_helper.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../utils/platform_info.dart';
|
import '../utils/platform_info.dart';
|
||||||
|
|
||||||
@@ -145,6 +146,19 @@ class NotificationService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format special message types for human-readable notifications.
|
||||||
|
static String formatNotificationText(String text) {
|
||||||
|
final trimmed = text.trim();
|
||||||
|
final reaction = ReactionHelper.parseReaction(trimmed);
|
||||||
|
if (reaction != null) {
|
||||||
|
return 'Reacted ${reaction.emoji}';
|
||||||
|
}
|
||||||
|
if (RegExp(r'^g:[A-Za-z0-9_-]+$').hasMatch(trimmed)) {
|
||||||
|
return 'Sent a GIF';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showMessageNotificationImpl({
|
Future<void> _showMessageNotificationImpl({
|
||||||
required String contactName,
|
required String contactName,
|
||||||
required String message,
|
required String message,
|
||||||
@@ -187,7 +201,7 @@ class NotificationService {
|
|||||||
await _notifications.show(
|
await _notifications.show(
|
||||||
id: contactId?.hashCode ?? 0,
|
id: contactId?.hashCode ?? 0,
|
||||||
title: contactName,
|
title: contactName,
|
||||||
body: message,
|
body: formatNotificationText(message),
|
||||||
notificationDetails: notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
payload: 'message:$contactId',
|
payload: 'message:$contactId',
|
||||||
);
|
);
|
||||||
@@ -232,7 +246,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,
|
||||||
@@ -281,7 +297,7 @@ class NotificationService {
|
|||||||
macOS: macDetails,
|
macOS: macDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
final preview = message.trim();
|
final preview = formatNotificationText(message.trim());
|
||||||
final body = preview.isEmpty
|
final body = preview.isEmpty
|
||||||
? _l10n.notification_receivedNewMessage
|
? _l10n.notification_receivedNewMessage
|
||||||
: preview;
|
: preview;
|
||||||
@@ -331,6 +347,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)
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
@@ -373,6 +444,7 @@ class NotificationService {
|
|||||||
|
|
||||||
Future<void> showChannelMessageNotification({
|
Future<void> showChannelMessageNotification({
|
||||||
required String channelName,
|
required String channelName,
|
||||||
|
required String senderName,
|
||||||
required String message,
|
required String message,
|
||||||
int? channelIndex,
|
int? channelIndex,
|
||||||
int? badgeCount,
|
int? badgeCount,
|
||||||
@@ -383,7 +455,7 @@ class NotificationService {
|
|||||||
_PendingNotification(
|
_PendingNotification(
|
||||||
type: _NotificationType.channelMessage,
|
type: _NotificationType.channelMessage,
|
||||||
title: channelName,
|
title: channelName,
|
||||||
body: message,
|
body: '$senderName: $message',
|
||||||
id: channelIndex?.toString(),
|
id: channelIndex?.toString(),
|
||||||
badgeCount: badgeCount,
|
badgeCount: badgeCount,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
final Map<String, ContactPathHistory> _cache = {};
|
final Map<String, ContactPathHistory> _cache = {};
|
||||||
final Map<String, int> _autoRotationIndex = {};
|
final Map<String, int> _autoRotationIndex = {};
|
||||||
final Map<String, _FloodStats> _floodStats = {};
|
final Map<String, _FloodStats> _floodStats = {};
|
||||||
|
final Set<String> _pendingLoads = {};
|
||||||
|
final Map<String, List<_DeferredPathRecord>> _deferredRecords = {};
|
||||||
|
|
||||||
// LRU cache eviction tracking
|
// LRU cache eviction tracking
|
||||||
static const int _maxCachedContacts = 50;
|
static const int _maxCachedContacts = 50;
|
||||||
@@ -18,7 +20,6 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
|
|
||||||
int _version = 0;
|
int _version = 0;
|
||||||
int get version => _version;
|
int get version => _version;
|
||||||
static const int _autoRotationTopCount = 3;
|
|
||||||
|
|
||||||
PathHistoryService(this._storage);
|
PathHistoryService(this._storage);
|
||||||
|
|
||||||
@@ -26,17 +27,21 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
// Load cached path histories on startup if needed
|
// Load cached path histories on startup if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
void handlePathUpdated(Contact contact) {
|
void handlePathUpdated(Contact contact, {double initialWeight = 1.0}) {
|
||||||
if (contact.pathLength < 0) return;
|
if (contact.pathLength < 0 && contact.path.isEmpty) return;
|
||||||
|
final hopCount = contact.pathLength < 0
|
||||||
|
? contact.path.length
|
||||||
|
: contact.pathLength;
|
||||||
_addPathRecord(
|
_addPathRecord(
|
||||||
contactPubKeyHex: contact.publicKeyHex,
|
contactPubKeyHex: contact.publicKeyHex,
|
||||||
hopCount: contact.pathLength,
|
hopCount: hopCount,
|
||||||
tripTimeMs: 0,
|
tripTimeMs: 0,
|
||||||
wasFloodDiscovery: true,
|
wasFloodDiscovery: true,
|
||||||
pathBytes: contact.path,
|
pathBytes: contact.path,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
failureCount: 0,
|
failureCount: 0,
|
||||||
|
routeWeight: initialWeight,
|
||||||
|
timestamp: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +59,44 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
pathBytes: selection.pathBytes,
|
pathBytes: selection.pathBytes,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
failureCount: 0,
|
failureCount: 0,
|
||||||
|
timestamp: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a flood message is delivered, credit the contact's current device
|
||||||
|
/// path so that the route the ACK traveled back through gets a weight boost.
|
||||||
|
void recordFloodPathAttribution({
|
||||||
|
required String contactPubKeyHex,
|
||||||
|
required List<int> pathBytes,
|
||||||
|
required int hopCount,
|
||||||
|
int? tripTimeMs,
|
||||||
|
double successIncrement = 0.5,
|
||||||
|
double maxWeight = 5.0,
|
||||||
|
}) {
|
||||||
|
if (pathBytes.isEmpty || hopCount < 0) return;
|
||||||
|
|
||||||
|
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
|
||||||
|
final successCount = (existing?.successCount ?? 0) + 1;
|
||||||
|
final failureCount = existing?.failureCount ?? 0;
|
||||||
|
|
||||||
|
final currentWeight = existing?.routeWeight ?? 1.0;
|
||||||
|
final newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'Flood path attribution: crediting path [${pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',')}] '
|
||||||
|
'for $contactPubKeyHex (weight $currentWeight → $newWeight)',
|
||||||
|
);
|
||||||
|
|
||||||
|
_addPathRecord(
|
||||||
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
|
hopCount: hopCount,
|
||||||
|
tripTimeMs: tripTimeMs ?? existing?.tripTimeMs ?? 0,
|
||||||
|
wasFloodDiscovery: true,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
successCount: successCount,
|
||||||
|
failureCount: failureCount,
|
||||||
|
routeWeight: newWeight,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +105,9 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
PathSelection selection, {
|
PathSelection selection, {
|
||||||
required bool success,
|
required bool success,
|
||||||
int? tripTimeMs,
|
int? tripTimeMs,
|
||||||
|
double successIncrement = 0.5,
|
||||||
|
double failureDecrement = 0.5,
|
||||||
|
double maxWeight = 5.0,
|
||||||
}) {
|
}) {
|
||||||
if (selection.useFlood) {
|
if (selection.useFlood) {
|
||||||
final stats = _floodStats.putIfAbsent(
|
final stats = _floodStats.putIfAbsent(
|
||||||
@@ -82,6 +128,18 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
|
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
|
||||||
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
|
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
|
||||||
|
|
||||||
|
final currentWeight = existing?.routeWeight ?? 1.0;
|
||||||
|
double newWeight;
|
||||||
|
if (success) {
|
||||||
|
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
|
||||||
|
} else {
|
||||||
|
newWeight = currentWeight - failureDecrement;
|
||||||
|
if (newWeight <= 0) {
|
||||||
|
removePathRecord(contactPubKeyHex, selection.pathBytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_addPathRecord(
|
_addPathRecord(
|
||||||
contactPubKeyHex: contactPubKeyHex,
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
hopCount: selection.hopCount,
|
hopCount: selection.hopCount,
|
||||||
@@ -90,37 +148,68 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
pathBytes: selection.pathBytes,
|
pathBytes: selection.pathBytes,
|
||||||
successCount: successCount,
|
successCount: successCount,
|
||||||
failureCount: failureCount,
|
failureCount: failureCount,
|
||||||
|
routeWeight: newWeight,
|
||||||
|
timestamp: success ? DateTime.now() : existing?.timestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
|
PathSelection selectPathForAttempt(
|
||||||
final ranked = _getRankedPaths(
|
String contactPubKeyHex, {
|
||||||
contactPubKeyHex,
|
required int attemptIndex,
|
||||||
).take(_autoRotationTopCount).toList();
|
required int maxRetries,
|
||||||
|
List<PathSelection> recentSelections = const [],
|
||||||
|
}) {
|
||||||
|
if (maxRetries <= 0 || attemptIndex >= maxRetries - 1) {
|
||||||
|
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final ranked = _getRankedPaths(contactPubKeyHex);
|
||||||
if (ranked.isEmpty) {
|
if (ranked.isEmpty) {
|
||||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
_trackAccess(contactPubKeyHex);
|
_trackAccess(contactPubKeyHex);
|
||||||
|
|
||||||
final selections =
|
final recentPaths = recentSelections
|
||||||
ranked
|
.where((selection) => !selection.useFlood)
|
||||||
.map(
|
.map((selection) => selection.pathBytes)
|
||||||
(path) => PathSelection(
|
.toList();
|
||||||
pathBytes: path.pathBytes,
|
final candidates = recentPaths.isEmpty
|
||||||
hopCount: path.hopCount,
|
? ranked
|
||||||
useFlood: false,
|
: ranked
|
||||||
),
|
.where(
|
||||||
)
|
(path) => !recentPaths.any(
|
||||||
.toList()
|
(recentPath) => _pathsEqual(path.pathBytes, recentPath),
|
||||||
..add(
|
),
|
||||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
)
|
||||||
);
|
.toList();
|
||||||
|
final selected = candidates.isNotEmpty
|
||||||
|
? (recentPaths.isEmpty
|
||||||
|
? _selectRotatedCandidate(contactPubKeyHex, candidates)
|
||||||
|
: candidates.first)
|
||||||
|
: ranked.first;
|
||||||
|
|
||||||
|
return PathSelection(
|
||||||
|
pathBytes: selected.pathBytes,
|
||||||
|
hopCount: selected.hopCount,
|
||||||
|
useFlood: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord _selectRotatedCandidate(
|
||||||
|
String contactPubKeyHex,
|
||||||
|
List<PathRecord> candidates,
|
||||||
|
) {
|
||||||
|
if (candidates.length <= 1) {
|
||||||
|
_autoRotationIndex[contactPubKeyHex] = 0;
|
||||||
|
return candidates.first;
|
||||||
|
}
|
||||||
|
|
||||||
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
|
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
|
||||||
final selection = selections[currentIndex % selections.length];
|
final selectedIndex = currentIndex % candidates.length;
|
||||||
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
|
_autoRotationIndex[contactPubKeyHex] =
|
||||||
return selection;
|
(selectedIndex + 1) % candidates.length;
|
||||||
|
return candidates[selectedIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addPathRecord({
|
void _addPathRecord({
|
||||||
@@ -131,37 +220,68 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
required List<int> pathBytes,
|
required List<int> pathBytes,
|
||||||
required int successCount,
|
required int successCount,
|
||||||
required int failureCount,
|
required int failureCount,
|
||||||
|
double routeWeight = 1.0,
|
||||||
|
DateTime? timestamp,
|
||||||
}) {
|
}) {
|
||||||
var history = _cache[contactPubKeyHex];
|
var history = _cache[contactPubKeyHex];
|
||||||
|
|
||||||
if (history == null) {
|
if (history == null) {
|
||||||
|
// If a load is already in progress, defer this record
|
||||||
|
if (_pendingLoads.contains(contactPubKeyHex)) {
|
||||||
|
_deferredRecords.putIfAbsent(contactPubKeyHex, () => []);
|
||||||
|
_deferredRecords[contactPubKeyHex]!.add(
|
||||||
|
_DeferredPathRecord(
|
||||||
|
hopCount: hopCount,
|
||||||
|
tripTimeMs: tripTimeMs,
|
||||||
|
wasFloodDiscovery: wasFloodDiscovery,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
successCount: successCount,
|
||||||
|
failureCount: failureCount,
|
||||||
|
routeWeight: routeWeight,
|
||||||
|
timestamp: timestamp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingLoads.add(contactPubKeyHex);
|
||||||
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
|
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
|
||||||
if (loaded != null) {
|
_cache[contactPubKeyHex] =
|
||||||
_cache[contactPubKeyHex] = loaded;
|
loaded ??
|
||||||
_addPathRecordInternal(
|
ContactPathHistory(
|
||||||
contactPubKeyHex,
|
contactPubKeyHex: contactPubKeyHex,
|
||||||
hopCount,
|
recentPaths: [],
|
||||||
tripTimeMs,
|
);
|
||||||
wasFloodDiscovery,
|
_addPathRecordInternal(
|
||||||
pathBytes,
|
contactPubKeyHex,
|
||||||
successCount,
|
hopCount,
|
||||||
failureCount,
|
tripTimeMs,
|
||||||
);
|
wasFloodDiscovery,
|
||||||
} else {
|
pathBytes,
|
||||||
_cache[contactPubKeyHex] = ContactPathHistory(
|
successCount,
|
||||||
contactPubKeyHex: contactPubKeyHex,
|
failureCount,
|
||||||
recentPaths: [],
|
routeWeight,
|
||||||
);
|
timestamp,
|
||||||
_addPathRecordInternal(
|
);
|
||||||
contactPubKeyHex,
|
|
||||||
hopCount,
|
// Apply any deferred records
|
||||||
tripTimeMs,
|
final deferred = _deferredRecords.remove(contactPubKeyHex);
|
||||||
wasFloodDiscovery,
|
if (deferred != null) {
|
||||||
pathBytes,
|
for (final record in deferred) {
|
||||||
successCount,
|
_addPathRecordInternal(
|
||||||
failureCount,
|
contactPubKeyHex,
|
||||||
);
|
record.hopCount,
|
||||||
|
record.tripTimeMs,
|
||||||
|
record.wasFloodDiscovery,
|
||||||
|
record.pathBytes,
|
||||||
|
record.successCount,
|
||||||
|
record.failureCount,
|
||||||
|
record.routeWeight,
|
||||||
|
record.timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
_pendingLoads.remove(contactPubKeyHex);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -174,6 +294,8 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
pathBytes,
|
pathBytes,
|
||||||
successCount,
|
successCount,
|
||||||
failureCount,
|
failureCount,
|
||||||
|
routeWeight,
|
||||||
|
timestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +307,8 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
List<int> pathBytes,
|
List<int> pathBytes,
|
||||||
int successCount,
|
int successCount,
|
||||||
int failureCount,
|
int failureCount,
|
||||||
|
double routeWeight,
|
||||||
|
DateTime? timestamp,
|
||||||
) {
|
) {
|
||||||
var history = _cache[contactPubKeyHex];
|
var history = _cache[contactPubKeyHex];
|
||||||
if (history == null) return;
|
if (history == null) return;
|
||||||
@@ -198,16 +322,18 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
tripTimeMs = existing.tripTimeMs;
|
tripTimeMs = existing.tripTimeMs;
|
||||||
}
|
}
|
||||||
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
|
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
|
||||||
|
timestamp ??= existing.timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
final newRecord = PathRecord(
|
final newRecord = PathRecord(
|
||||||
hopCount: hopCount,
|
hopCount: hopCount,
|
||||||
tripTimeMs: tripTimeMs,
|
tripTimeMs: tripTimeMs,
|
||||||
timestamp: DateTime.now(),
|
timestamp: timestamp,
|
||||||
wasFloodDiscovery: wasFloodDiscovery,
|
wasFloodDiscovery: wasFloodDiscovery,
|
||||||
pathBytes: pathBytes,
|
pathBytes: pathBytes,
|
||||||
successCount: successCount,
|
successCount: successCount,
|
||||||
failureCount: failureCount,
|
failureCount: failureCount,
|
||||||
|
routeWeight: routeWeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
final updatedPaths = List<PathRecord>.from(history.recentPaths);
|
final updatedPaths = List<PathRecord>.from(history.recentPaths);
|
||||||
@@ -275,6 +401,23 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
return history?.mostRecent;
|
return history?.mostRecent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
({
|
||||||
|
int successCount,
|
||||||
|
int failureCount,
|
||||||
|
int lastTripTimeMs,
|
||||||
|
DateTime? lastUsed,
|
||||||
|
})?
|
||||||
|
getFloodStats(String contactPubKeyHex) {
|
||||||
|
final stats = _floodStats[contactPubKeyHex];
|
||||||
|
if (stats == null) return null;
|
||||||
|
return (
|
||||||
|
successCount: stats.successCount,
|
||||||
|
failureCount: stats.failureCount,
|
||||||
|
lastTripTimeMs: stats.lastTripTimeMs,
|
||||||
|
lastUsed: stats.lastUsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearPathHistory(String contactPubKeyHex) async {
|
Future<void> clearPathHistory(String contactPubKeyHex) async {
|
||||||
_cache.remove(contactPubKeyHex);
|
_cache.remove(contactPubKeyHex);
|
||||||
_cacheAccessOrder.remove(contactPubKeyHex);
|
_cacheAccessOrder.remove(contactPubKeyHex);
|
||||||
@@ -322,26 +465,81 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
|
|
||||||
final ranked = List<PathRecord>.from(history.recentPaths)
|
final ranked = List<PathRecord>.from(history.recentPaths)
|
||||||
..removeWhere((p) => p.pathBytes.isEmpty);
|
..removeWhere((p) => p.pathBytes.isEmpty);
|
||||||
|
final fastestTripMs = _getFastestKnownTripMs(ranked);
|
||||||
|
final highestRouteWeight = _getHighestKnownRouteWeight(ranked);
|
||||||
|
|
||||||
ranked.sort((a, b) {
|
ranked.sort((a, b) {
|
||||||
final aRate =
|
final scoreCompare =
|
||||||
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
|
_scorePathRecord(
|
||||||
final bRate =
|
b,
|
||||||
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
|
fastestTripMs: fastestTripMs,
|
||||||
if (aRate != bRate) return bRate.compareTo(aRate);
|
highestRouteWeight: highestRouteWeight,
|
||||||
if (a.successCount != b.successCount) {
|
).compareTo(
|
||||||
return b.successCount.compareTo(a.successCount);
|
_scorePathRecord(
|
||||||
|
a,
|
||||||
|
fastestTripMs: fastestTripMs,
|
||||||
|
highestRouteWeight: highestRouteWeight,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (scoreCompare != 0) {
|
||||||
|
return scoreCompare;
|
||||||
|
}
|
||||||
|
if (a.routeWeight != b.routeWeight) {
|
||||||
|
return b.routeWeight.compareTo(a.routeWeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
|
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
|
||||||
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
|
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
|
||||||
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
|
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
|
||||||
return b.timestamp.compareTo(a.timestamp);
|
final aTime = a.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bTime = b.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
return bTime.compareTo(aTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
return ranked;
|
return ranked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _getFastestKnownTripMs(List<PathRecord> paths) {
|
||||||
|
final knownTrips = paths
|
||||||
|
.where((path) => path.tripTimeMs > 0)
|
||||||
|
.map((path) => path.tripTimeMs)
|
||||||
|
.toList();
|
||||||
|
if (knownTrips.isEmpty) return null;
|
||||||
|
return knownTrips.reduce((a, b) => a < b ? a : b);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getHighestKnownRouteWeight(List<PathRecord> paths) {
|
||||||
|
if (paths.isEmpty) return 1.0;
|
||||||
|
final highestWeight = paths
|
||||||
|
.map((path) => path.routeWeight)
|
||||||
|
.reduce((a, b) => a > b ? a : b);
|
||||||
|
return highestWeight <= 0 ? 1.0 : highestWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _scorePathRecord(
|
||||||
|
PathRecord path, {
|
||||||
|
required int? fastestTripMs,
|
||||||
|
required double highestRouteWeight,
|
||||||
|
}) {
|
||||||
|
final totalAttempts = path.successCount + path.failureCount;
|
||||||
|
final reliability = (path.successCount + 1) / (totalAttempts + 2);
|
||||||
|
final latency = fastestTripMs == null || path.tripTimeMs <= 0
|
||||||
|
? 0.6
|
||||||
|
: (fastestTripMs / path.tripTimeMs).clamp(0.0, 1.0);
|
||||||
|
final freshness = path.timestamp == null
|
||||||
|
? 0.0
|
||||||
|
: 1.0 /
|
||||||
|
(1.0 +
|
||||||
|
(DateTime.now().difference(path.timestamp!).inMinutes /
|
||||||
|
60.0 /
|
||||||
|
24.0));
|
||||||
|
final routeWeight = (path.routeWeight / highestRouteWeight).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
return (reliability * 0.45) +
|
||||||
|
(latency * 0.25) +
|
||||||
|
(freshness * 0.1) +
|
||||||
|
(routeWeight * 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
bool _pathsEqual(List<int> a, List<int> b) {
|
bool _pathsEqual(List<int> a, List<int> b) {
|
||||||
return listEquals(a, b);
|
return listEquals(a, b);
|
||||||
}
|
}
|
||||||
@@ -369,6 +567,28 @@ class PathHistoryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DeferredPathRecord {
|
||||||
|
final int hopCount;
|
||||||
|
final int tripTimeMs;
|
||||||
|
final bool wasFloodDiscovery;
|
||||||
|
final List<int> pathBytes;
|
||||||
|
final int successCount;
|
||||||
|
final int failureCount;
|
||||||
|
final double routeWeight;
|
||||||
|
final DateTime? timestamp;
|
||||||
|
|
||||||
|
_DeferredPathRecord({
|
||||||
|
required this.hopCount,
|
||||||
|
required this.tripTimeMs,
|
||||||
|
required this.wasFloodDiscovery,
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.successCount,
|
||||||
|
required this.failureCount,
|
||||||
|
this.routeWeight = 1.0,
|
||||||
|
this.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class _FloodStats {
|
class _FloodStats {
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
int failureCount = 0;
|
int failureCount = 0;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import '../models/delivery_observation.dart';
|
||||||
import '../models/path_history.dart';
|
import '../models/path_history.dart';
|
||||||
import '../storage/prefs_manager.dart';
|
import '../storage/prefs_manager.dart';
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ class StorageService {
|
|||||||
static const String _pathHistoryPrefix = 'path_history_';
|
static const String _pathHistoryPrefix = 'path_history_';
|
||||||
static const String _pendingMessagesKey = 'pending_messages';
|
static const String _pendingMessagesKey = 'pending_messages';
|
||||||
static const String _repeaterPasswordsKey = 'repeater_passwords';
|
static const String _repeaterPasswordsKey = 'repeater_passwords';
|
||||||
|
static const String _deliveryObservationsKey = 'delivery_observations';
|
||||||
|
|
||||||
Future<void> savePathHistory(
|
Future<void> savePathHistory(
|
||||||
String contactPubKeyHex,
|
String contactPubKeyHex,
|
||||||
@@ -122,4 +124,33 @@ class StorageService {
|
|||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
await prefs.remove(_repeaterPasswordsKey);
|
await prefs.remove(_repeaterPasswordsKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveDeliveryObservations(
|
||||||
|
List<DeliveryObservation> observations,
|
||||||
|
) async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
|
||||||
|
await prefs.setString(_deliveryObservationsKey, jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
final jsonStr = prefs.getString(_deliveryObservationsKey);
|
||||||
|
|
||||||
|
if (jsonStr == null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(jsonStr) as List;
|
||||||
|
return list
|
||||||
|
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearDeliveryObservations() async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
await prefs.remove(_deliveryObservationsKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:ml_algo/ml_algo.dart';
|
||||||
|
import 'package:ml_dataframe/ml_dataframe.dart';
|
||||||
|
import '../models/delivery_observation.dart';
|
||||||
|
import 'storage_service.dart';
|
||||||
|
|
||||||
|
class _ContactStats {
|
||||||
|
int count = 0;
|
||||||
|
double _sum = 0;
|
||||||
|
|
||||||
|
void add(double ms) {
|
||||||
|
count++;
|
||||||
|
_sum += ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get mean => _sum / count;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeoutPredictionService extends ChangeNotifier {
|
||||||
|
final StorageService? _storage;
|
||||||
|
|
||||||
|
static const int minObservations = 10;
|
||||||
|
static const int maxObservations = 100;
|
||||||
|
static const int _retrainInterval = 5;
|
||||||
|
// 1.5x multiplier on raw prediction to account for variance in delivery
|
||||||
|
// times — tight enough to improve on worst-case physics, loose enough
|
||||||
|
// to avoid premature timeouts from model noise.
|
||||||
|
static const double _safetyMargin = 1.5;
|
||||||
|
static const int _minContactObservations = 10;
|
||||||
|
|
||||||
|
List<DeliveryObservation> _observations = [];
|
||||||
|
LinearRegressor? _model;
|
||||||
|
List<String> _activeFeatures = [];
|
||||||
|
int _observationsSinceLastTrain = 0;
|
||||||
|
final Map<String, _ContactStats> _contactStats = {};
|
||||||
|
Timer? _persistTimer;
|
||||||
|
|
||||||
|
TimeoutPredictionService(StorageService storage) : _storage = storage;
|
||||||
|
TimeoutPredictionService.noStorage() : _storage = null;
|
||||||
|
|
||||||
|
int get observationCount => _observations.length;
|
||||||
|
bool get hasModel => _model != null;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
_observations = await _storage?.loadDeliveryObservations() ?? [];
|
||||||
|
_rebuildContactStats();
|
||||||
|
|
||||||
|
if (_observations.length >= minObservations) {
|
||||||
|
_trainModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: initialized with ${_observations.length} observations, '
|
||||||
|
'model=${_model != null ? "ready" : "waiting for data"}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordObservation({
|
||||||
|
required String contactKey,
|
||||||
|
required int pathLength,
|
||||||
|
required int messageBytes,
|
||||||
|
required int tripTimeMs,
|
||||||
|
int secondsSinceLastRx = 0,
|
||||||
|
}) {
|
||||||
|
final observation = DeliveryObservation(
|
||||||
|
contactKey: contactKey,
|
||||||
|
pathLength: pathLength,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
secondsSinceLastRx: secondsSinceLastRx,
|
||||||
|
isFlood: pathLength < 0,
|
||||||
|
deliveryMs: tripTimeMs,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
_observations.add(observation);
|
||||||
|
if (_observations.length > maxObservations) {
|
||||||
|
_observations.removeAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
|
||||||
|
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
|
||||||
|
|
||||||
|
_observationsSinceLastTrain++;
|
||||||
|
if (_observationsSinceLastTrain >= _retrainInterval &&
|
||||||
|
_observations.length >= minObservations) {
|
||||||
|
_trainModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistTimer?.cancel();
|
||||||
|
_persistTimer = Timer(const Duration(seconds: 2), () {
|
||||||
|
_storage?.saveDeliveryObservations(_observations);
|
||||||
|
});
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
|
||||||
|
'(${_observations.length} total)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? predictTimeout({
|
||||||
|
String? contactKey,
|
||||||
|
required int pathLength,
|
||||||
|
required int messageBytes,
|
||||||
|
int secondsSinceLastRx = 0,
|
||||||
|
}) {
|
||||||
|
if (_model == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_activeFeatures.isEmpty) return null;
|
||||||
|
|
||||||
|
final allFeatures = {
|
||||||
|
'pathLength': pathLength.toDouble(),
|
||||||
|
'messageBytes': messageBytes.toDouble(),
|
||||||
|
'secSinceRx': secondsSinceLastRx.toDouble(),
|
||||||
|
'isFlood': pathLength < 0 ? 1.0 : 0.0,
|
||||||
|
};
|
||||||
|
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
|
||||||
|
|
||||||
|
final features = DataFrame(
|
||||||
|
[row],
|
||||||
|
headerExists: false,
|
||||||
|
header: _activeFeatures,
|
||||||
|
);
|
||||||
|
|
||||||
|
final prediction = _model!.predict(features);
|
||||||
|
final rawValue = prediction.rows.first.first;
|
||||||
|
var predictedMs = (rawValue is double)
|
||||||
|
? rawValue
|
||||||
|
: (rawValue as num).toDouble();
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: raw prediction=$predictedMs for '
|
||||||
|
'pathLength=$pathLength, messageBytes=$messageBytes, '
|
||||||
|
'features=$_activeFeatures',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity check: if prediction is negative or zero, fall back
|
||||||
|
if (predictedMs <= 0) return null;
|
||||||
|
|
||||||
|
// Blend with per-contact mean if enough data
|
||||||
|
if (contactKey != null) {
|
||||||
|
final stats = _contactStats[contactKey];
|
||||||
|
if (stats != null && stats.count >= _minContactObservations) {
|
||||||
|
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector clamps this between physics min/max bounds
|
||||||
|
final timeout = (predictedMs * _safetyMargin).ceil();
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: ML timeout ${timeout}ms '
|
||||||
|
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
|
||||||
|
);
|
||||||
|
return timeout;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('TimeoutPrediction: prediction failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trainModel() {
|
||||||
|
try {
|
||||||
|
// Build feature columns, then exclude any with zero variance
|
||||||
|
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
|
||||||
|
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
|
||||||
|
final allExtractors = <double Function(DeliveryObservation)>[
|
||||||
|
(o) => o.pathLength.toDouble(),
|
||||||
|
(o) => o.messageBytes.toDouble(),
|
||||||
|
(o) => o.secondsSinceLastRx.toDouble(),
|
||||||
|
(o) => o.isFlood ? 1.0 : 0.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
_activeFeatures = [];
|
||||||
|
for (var i = 0; i < allNames.length; i++) {
|
||||||
|
final values = _observations.map(allExtractors[i]).toSet();
|
||||||
|
if (values.length > 1) _activeFeatures.add(allNames[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeFeatures.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: no features with variance, skipping training',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final header = [..._activeFeatures, 'deliveryMs'];
|
||||||
|
final rows = _observations.map((o) {
|
||||||
|
final row = <double>[];
|
||||||
|
for (var i = 0; i < allNames.length; i++) {
|
||||||
|
if (_activeFeatures.contains(allNames[i])) {
|
||||||
|
row.add(allExtractors[i](o));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.add(o.deliveryMs.toDouble());
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = DataFrame([header, ...rows], headerExists: true);
|
||||||
|
|
||||||
|
_model = LinearRegressor(data, 'deliveryMs');
|
||||||
|
_observationsSinceLastTrain = 0;
|
||||||
|
|
||||||
|
// Log training summary with sample predictions
|
||||||
|
final avgMs =
|
||||||
|
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
|
||||||
|
_observations.length;
|
||||||
|
debugPrint(
|
||||||
|
'TimeoutPrediction: trained on ${_observations.length} observations '
|
||||||
|
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('TimeoutPrediction: training failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_persistTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuildContactStats() {
|
||||||
|
_contactStats.clear();
|
||||||
|
for (final obs in _observations) {
|
||||||
|
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
|
||||||
|
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,6 +189,10 @@ class UsbSerialService {
|
|||||||
serial.setStopBits1();
|
serial.setStopBits1();
|
||||||
serial.setFlowControlNone();
|
serial.setFlowControlNone();
|
||||||
serial.setRTS(false);
|
serial.setRTS(false);
|
||||||
|
// Toggle DTR low→high so the device sees a fresh connection even
|
||||||
|
// if the previous disconnect didn't cleanly signal DTR drop.
|
||||||
|
serial.setDTR(false);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
serial.setDTR(true);
|
serial.setDTR(true);
|
||||||
_serial = serial;
|
_serial = serial;
|
||||||
// Update the normalized port name to whichever candidate succeeded.
|
// Update the normalized port name to whichever candidate succeeded.
|
||||||
@@ -249,6 +253,21 @@ class UsbSerialService {
|
|||||||
_status = UsbSerialStatus.connected;
|
_status = UsbSerialStatus.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> writeRaw(Uint8List data) async {
|
||||||
|
if (!isConnected) {
|
||||||
|
throw StateError('USB serial port is not open');
|
||||||
|
}
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
try {
|
||||||
|
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
throw StateError(error.message ?? error.code);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_serial!.write(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> write(Uint8List data) async {
|
Future<void> write(Uint8List data) async {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
throw StateError('USB serial port is not open');
|
throw StateError('USB serial port is not open');
|
||||||
@@ -300,6 +319,7 @@ class UsbSerialService {
|
|||||||
_serial = null;
|
_serial = null;
|
||||||
try {
|
try {
|
||||||
if (serial?.isOpen() == FlOpenStatus.open) {
|
if (serial?.isOpen() == FlOpenStatus.open) {
|
||||||
|
serial?.setDTR(false);
|
||||||
serial?.closePort();
|
serial?.closePort();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -350,6 +370,7 @@ class UsbSerialService {
|
|||||||
final serial = _serial;
|
final serial = _serial;
|
||||||
try {
|
try {
|
||||||
if (serial?.isOpen() == FlOpenStatus.open) {
|
if (serial?.isOpen() == FlOpenStatus.open) {
|
||||||
|
serial?.setDTR(false);
|
||||||
serial?.closePort(); // synchronous C call — kills the SerialThread
|
serial?.closePort(); // synchronous C call — kills the SerialThread
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -127,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');
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class ChannelMessageStore {
|
|||||||
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
|
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
|
||||||
'repeats': msg.repeats.map(_repeatToJson).toList(),
|
'repeats': msg.repeats.map(_repeatToJson).toList(),
|
||||||
'messageId': msg.messageId,
|
'messageId': msg.messageId,
|
||||||
|
'packetHash': msg.packetHash,
|
||||||
'replyToMessageId': msg.replyToMessageId,
|
'replyToMessageId': msg.replyToMessageId,
|
||||||
'replyToSenderName': msg.replyToSenderName,
|
'replyToSenderName': msg.replyToSenderName,
|
||||||
'replyToText': msg.replyToText,
|
'replyToText': msg.replyToText,
|
||||||
@@ -143,6 +144,7 @@ class ChannelMessageStore {
|
|||||||
const [],
|
const [],
|
||||||
channelIndex: json['channelIndex'] as int?,
|
channelIndex: json['channelIndex'] as int?,
|
||||||
messageId: json['messageId'] as String?,
|
messageId: json['messageId'] as String?,
|
||||||
|
packetHash: json['packetHash'] as String?,
|
||||||
replyToMessageId: json['replyToMessageId'] as String?,
|
replyToMessageId: json['replyToMessageId'] as String?,
|
||||||
replyToSenderName: json['replyToSenderName'] as String?,
|
replyToSenderName: json['replyToSenderName'] as String?,
|
||||||
replyToText: json['replyToText'] as String?,
|
replyToText: json['replyToText'] as String?,
|
||||||
|
|||||||
@@ -85,9 +85,7 @@ class MessageStore {
|
|||||||
'messageId': msg.messageId,
|
'messageId': msg.messageId,
|
||||||
'retryCount': msg.retryCount,
|
'retryCount': msg.retryCount,
|
||||||
'estimatedTimeoutMs': msg.estimatedTimeoutMs,
|
'estimatedTimeoutMs': msg.estimatedTimeoutMs,
|
||||||
'expectedAckHash': msg.expectedAckHash != null
|
'expectedAckHash': msg.expectedAckHash,
|
||||||
? base64Encode(msg.expectedAckHash!)
|
|
||||||
: null,
|
|
||||||
'sentAt': msg.sentAt?.millisecondsSinceEpoch,
|
'sentAt': msg.sentAt?.millisecondsSinceEpoch,
|
||||||
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
|
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
|
||||||
'tripTimeMs': msg.tripTimeMs,
|
'tripTimeMs': msg.tripTimeMs,
|
||||||
@@ -96,6 +94,9 @@ class MessageStore {
|
|||||||
? base64Encode(msg.pathBytes)
|
? base64Encode(msg.pathBytes)
|
||||||
: null,
|
: null,
|
||||||
'reactions': msg.reactions,
|
'reactions': msg.reactions,
|
||||||
|
'reactionStatuses': msg.reactionStatuses.map(
|
||||||
|
(key, value) => MapEntry(key, value.index),
|
||||||
|
),
|
||||||
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
|
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -116,9 +117,7 @@ class MessageStore {
|
|||||||
messageId: json['messageId'] as String?,
|
messageId: json['messageId'] as String?,
|
||||||
retryCount: json['retryCount'] as int? ?? 0,
|
retryCount: json['retryCount'] as int? ?? 0,
|
||||||
estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?,
|
estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?,
|
||||||
expectedAckHash: json['expectedAckHash'] != null
|
expectedAckHash: json['expectedAckHash'] as int? ?? 0,
|
||||||
? Uint8List.fromList(base64Decode(json['expectedAckHash'] as String))
|
|
||||||
: null,
|
|
||||||
sentAt: json['sentAt'] != null
|
sentAt: json['sentAt'] != null
|
||||||
? DateTime.fromMillisecondsSinceEpoch(json['sentAt'] as int)
|
? DateTime.fromMillisecondsSinceEpoch(json['sentAt'] as int)
|
||||||
: null,
|
: null,
|
||||||
@@ -135,6 +134,11 @@ class MessageStore {
|
|||||||
(key, value) => MapEntry(key, value as int),
|
(key, value) => MapEntry(key, value as int),
|
||||||
) ??
|
) ??
|
||||||
{},
|
{},
|
||||||
|
reactionStatuses:
|
||||||
|
(json['reactionStatuses'] as Map<String, dynamic>?)?.map(
|
||||||
|
(key, value) => MapEntry(key, MessageStatus.values[value as int]),
|
||||||
|
) ??
|
||||||
|
{},
|
||||||
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
|
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
|
||||||
? Uint8List.fromList(
|
? Uint8List.fromList(
|
||||||
base64Decode(json['fourByteRoomContactKey'] as String),
|
base64Decode(json['fourByteRoomContactKey'] as String),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../helpers/path_helper.dart';
|
||||||
import '../services/path_history_service.dart';
|
import '../services/path_history_service.dart';
|
||||||
import 'path_selection_dialog.dart';
|
import 'path_selection_dialog.dart';
|
||||||
|
|
||||||
@@ -33,14 +34,26 @@ 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) {
|
||||||
|
if (time == null) return '—';
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final diff = DateTime.now().difference(time);
|
final diff = DateTime.now().difference(time);
|
||||||
if (diff.inSeconds < 60) return l10n.time_justNow;
|
if (diff.inSeconds < 60) return l10n.time_justNow;
|
||||||
@@ -61,15 +74,31 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final formattedPath = pathBytes
|
final connector = context.read<MeshCoreConnector>();
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
final allContacts = connector.allContacts;
|
||||||
.join(',');
|
|
||||||
|
final formattedPath = PathHelper.formatPathHex(pathBytes);
|
||||||
|
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(l10n.chat_fullPath),
|
title: Text(l10n.chat_fullPath),
|
||||||
content: SelectableText(formattedPath),
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SelectableText(formattedPath),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SelectableText(
|
||||||
|
resolvedNames,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () => Navigator.push(
|
||||||
@@ -78,7 +107,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 +136,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();
|
||||||
|
|
||||||
@@ -262,16 +291,17 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
child: Text(
|
child: Text(
|
||||||
'${path.hopCount}',
|
path.routeWeight.toStringAsFixed(1),
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
l10n.chat_hopsCount(path.hopCount),
|
l10n.chat_hopsCount(path.hopCount),
|
||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
),
|
),
|
||||||
|
isThreeLine: true,
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}',
|
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}\n${path.successCount} ${l10n.chat_successes} • Score: ${path.routeWeight.toStringAsFixed(1)}',
|
||||||
style: const TextStyle(fontSize: 11),
|
style: const TextStyle(fontSize: 11),
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
@@ -346,6 +376,40 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||||||
Text(l10n.chat_noPathHistoryYet),
|
Text(l10n.chat_noPathHistoryYet),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
],
|
],
|
||||||
|
// Flood delivery stats
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final floodStats = pathService.getFloodStats(
|
||||||
|
currentContact.publicKeyHex,
|
||||||
|
);
|
||||||
|
if (floodStats == null ||
|
||||||
|
(floodStats.successCount == 0 &&
|
||||||
|
floodStats.failureCount == 0)) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
child: Icon(Icons.waves, size: 16),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Flood Mode',
|
||||||
|
style: TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${floodStats.successCount} ${l10n.chat_successes} / ${floodStats.failureCount} failures'
|
||||||
|
'${floodStats.lastTripTimeMs > 0 ? ' • ${(floodStats.lastTripTimeMs / 1000).toStringAsFixed(2)}s' : ''}'
|
||||||
|
'${floodStats.lastUsed != null ? ' • ${_formatRelativeTime(context, floodStats.lastUsed!)}' : ''}',
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
l10n.chat_pathActions,
|
l10n.chat_pathActions,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||||||
|
|
||||||
void _filterValidContacts() {
|
void _filterValidContacts() {
|
||||||
_validContacts = widget.availableContacts
|
_validContacts = widget.availableContacts
|
||||||
.where((c) => c.type == 2 || c.type == 3)
|
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,21 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
|
|
||||||
bool _isLoggingIn = false;
|
bool _isLoggingIn = false;
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.repeater.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
orElse: () => widget.repeater,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.repeater;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
|
|||||||
@@ -64,11 +64,22 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
|
|
||||||
bool _isLoggingIn = false;
|
bool _isLoggingIn = false;
|
||||||
|
|
||||||
|
int _resolveRepeaterIndex = -1;
|
||||||
|
|
||||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
return connector.contacts.firstWhere(
|
if (_resolveRepeaterIndex >= 0 &&
|
||||||
|
_resolveRepeaterIndex < connector.contacts.length &&
|
||||||
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||||
|
widget.room.publicKeyHex) {
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
|
}
|
||||||
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||||
(c) => c.publicKeyHex == widget.room.publicKeyHex,
|
(c) => c.publicKeyHex == widget.room.publicKeyHex,
|
||||||
orElse: () => widget.room,
|
|
||||||
);
|
);
|
||||||
|
if (_resolveRepeaterIndex == -1) {
|
||||||
|
return widget.room;
|
||||||
|
}
|
||||||
|
return connector.contacts[_resolveRepeaterIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
|
|||||||
@@ -157,10 +157,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
repeater.snr,
|
repeater.snr,
|
||||||
widget.connector.currentSf,
|
widget.connector.currentSf,
|
||||||
);
|
);
|
||||||
final allContacts = [
|
final allContacts = widget.connector.allContacts;
|
||||||
...widget.connector.contacts,
|
|
||||||
...widget.connector.discoveredContacts,
|
|
||||||
];
|
|
||||||
final name = allContacts
|
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)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import flutter_blue_plus_darwin
|
|||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import mobile_scanner
|
import mobile_scanner
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
@@ -21,7 +20,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
|||||||
+3
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 6.0.0+7
|
version: 7.0.0+8
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -69,6 +69,8 @@ dependencies:
|
|||||||
material_symbols_icons: ^4.2906.0
|
material_symbols_icons: ^4.2906.0
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
flutter_svg: ^2.0.10+1
|
flutter_svg: ^2.0.10+1
|
||||||
|
ml_algo: ^16.0.0
|
||||||
|
ml_dataframe: ^1.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||||
|
import 'package:meshcore_open/helpers/path_helper.dart';
|
||||||
|
import 'package:meshcore_open/models/contact.dart';
|
||||||
|
|
||||||
|
Contact _contact({
|
||||||
|
required int firstByte,
|
||||||
|
required String name,
|
||||||
|
required int type,
|
||||||
|
}) {
|
||||||
|
final key = Uint8List(32)..[0] = firstByte;
|
||||||
|
return Contact(
|
||||||
|
publicKey: key,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
pathLength: 0,
|
||||||
|
path: Uint8List(0),
|
||||||
|
lastSeen: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('resolvePathNames ignores chat nodes and keeps repeater/room nodes', () {
|
||||||
|
final contacts = [
|
||||||
|
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),
|
||||||
|
_contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater),
|
||||||
|
_contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom),
|
||||||
|
];
|
||||||
|
|
||||||
|
final resolved = PathHelper.resolvePathNames([0xF2, 0x7E, 0xBA], contacts);
|
||||||
|
|
||||||
|
expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/models/contact.dart';
|
||||||
|
import 'package:meshcore_open/models/path_history.dart';
|
||||||
|
import 'package:meshcore_open/models/app_settings.dart';
|
||||||
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
|
// Builds a valid contact frame with the given pathLen and optional overrides.
|
||||||
|
// Frame layout: [respCode(1)][pubKey(32)][type(1)][flags(1)][pathLen(1)][path(64)][name(32)][timestamp(4)][lat(4)][lon(4)]
|
||||||
|
Uint8List _buildContactFrame({
|
||||||
|
int pathLen = 0,
|
||||||
|
Uint8List? pubKey,
|
||||||
|
String name = 'TestNode',
|
||||||
|
}) {
|
||||||
|
final writer = BytesBuilder();
|
||||||
|
writer.addByte(respCodeContact); // 3
|
||||||
|
writer.add(
|
||||||
|
pubKey ?? Uint8List.fromList(List.generate(32, (i) => i + 1)),
|
||||||
|
); // valid pubkey
|
||||||
|
writer.addByte(1); // type
|
||||||
|
writer.addByte(0); // flags
|
||||||
|
writer.addByte(pathLen);
|
||||||
|
writer.add(Uint8List(64)); // path bytes (zeros)
|
||||||
|
// name (32 bytes, null-padded)
|
||||||
|
final nameBytes = Uint8List(32);
|
||||||
|
final encoded = name.codeUnits;
|
||||||
|
for (var i = 0; i < encoded.length && i < 31; i++) {
|
||||||
|
nameBytes[i] = encoded[i];
|
||||||
|
}
|
||||||
|
writer.add(nameBytes);
|
||||||
|
// timestamp (4 bytes LE) - some nonzero value
|
||||||
|
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00]));
|
||||||
|
// lat, lon (4 bytes each)
|
||||||
|
writer.add(Uint8List(4)); // lat
|
||||||
|
writer.add(Uint8List(4)); // lon
|
||||||
|
return Uint8List.fromList(writer.toBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Contact.fromFrame — pathLen mapping', () {
|
||||||
|
test('pathLen == 0 → pathLength == 0 (direct, NOT flood)', () {
|
||||||
|
final frame = _buildContactFrame(pathLen: 0);
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNotNull);
|
||||||
|
expect(contact!.pathLength, equals(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathLen == 1 → pathLength == 1', () {
|
||||||
|
final frame = _buildContactFrame(pathLen: 1);
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNotNull);
|
||||||
|
expect(contact!.pathLength, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathLen == 64 (maxPathSize) → pathLength == 64', () {
|
||||||
|
final frame = _buildContactFrame(pathLen: maxPathSize);
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNotNull);
|
||||||
|
expect(contact!.pathLength, equals(maxPathSize));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathLen == 0xFF → pathLength == -1 (flood)', () {
|
||||||
|
final frame = _buildContactFrame(pathLen: 0xFF);
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNotNull);
|
||||||
|
expect(contact!.pathLength, equals(-1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () {
|
||||||
|
final frame = _buildContactFrame(pathLen: 65);
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNotNull);
|
||||||
|
expect(contact!.pathLength, equals(-1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Contact.fromFrame — corrupt contact guards', () {
|
||||||
|
test('all-zero public key → returns null', () {
|
||||||
|
final zeroPubKey = Uint8List(32); // all zeros
|
||||||
|
final frame = _buildContactFrame(pubKey: zeroPubKey);
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mostly-zero public key (>16 zeros out of 32) → returns null', () {
|
||||||
|
// 17 zeros out of 32 bytes exceeds pubKeySize ~/ 2 == 16
|
||||||
|
final pubKey = Uint8List(32);
|
||||||
|
pubKey[0] = 0xAB;
|
||||||
|
pubKey[1] = 0xCD;
|
||||||
|
pubKey[2] = 0xEF;
|
||||||
|
pubKey[3] = 0x12;
|
||||||
|
pubKey[4] = 0x34;
|
||||||
|
pubKey[5] = 0x56;
|
||||||
|
pubKey[6] = 0x78;
|
||||||
|
pubKey[7] = 0x9A;
|
||||||
|
pubKey[8] = 0xBC;
|
||||||
|
pubKey[9] = 0xDE;
|
||||||
|
pubKey[10] = 0xF0;
|
||||||
|
pubKey[11] = 0x11;
|
||||||
|
pubKey[12] = 0x22;
|
||||||
|
pubKey[13] = 0x33;
|
||||||
|
pubKey[14] = 0x44;
|
||||||
|
// bytes 15–31 are zero: that is 17 zeros (indices 15..31 inclusive)
|
||||||
|
final frame = _buildContactFrame(pubKey: pubKey);
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid public key (few zeros) → returns Contact', () {
|
||||||
|
// Only 1 zero → well below the threshold
|
||||||
|
final pubKey = Uint8List.fromList(List.generate(32, (i) => i + 1));
|
||||||
|
pubKey[5] = 0; // one zero byte
|
||||||
|
final frame = _buildContactFrame(pubKey: pubKey);
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name with all non-printable characters → returns null', () {
|
||||||
|
// Build frame with a name composed entirely of control characters (< 0x20)
|
||||||
|
final nameBytes = Uint8List(32);
|
||||||
|
nameBytes[0] = 0x01;
|
||||||
|
nameBytes[1] = 0x02;
|
||||||
|
nameBytes[2] = 0x03;
|
||||||
|
// remaining are 0x00 (null terminator ends the string after index 2,
|
||||||
|
// so readCStringGreedy returns a 3-char string of non-printables)
|
||||||
|
final writer = BytesBuilder();
|
||||||
|
writer.addByte(respCodeContact);
|
||||||
|
writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1)));
|
||||||
|
writer.addByte(1); // type
|
||||||
|
writer.addByte(0); // flags
|
||||||
|
writer.addByte(0); // pathLen
|
||||||
|
writer.add(Uint8List(64)); // path
|
||||||
|
writer.add(nameBytes);
|
||||||
|
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp
|
||||||
|
writer.add(Uint8List(4)); // lat
|
||||||
|
writer.add(Uint8List(4)); // lon
|
||||||
|
final frame = Uint8List.fromList(writer.toBytes());
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name with valid printable characters → returns Contact', () {
|
||||||
|
final frame = _buildContactFrame(name: 'Alice');
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNotNull);
|
||||||
|
expect(contact!.name, equals('Alice'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'name with mix of printable and replacement chars → returns Contact (not all bad)',
|
||||||
|
() {
|
||||||
|
// Build a name with mostly printable chars and one replacement char (0xFFFD in codeUnits).
|
||||||
|
// utf8 allowMalformed: true maps invalid sequences to U+FFFD.
|
||||||
|
// We embed one invalid UTF-8 byte (0x80) among valid ASCII bytes.
|
||||||
|
// The decoded string will be "Hi\uFFFDThere" — not ALL bad, so should be accepted.
|
||||||
|
final nameBytes = Uint8List(32);
|
||||||
|
nameBytes[0] = 0x48; // 'H'
|
||||||
|
nameBytes[1] = 0x69; // 'i'
|
||||||
|
nameBytes[2] = 0x80; // invalid UTF-8 → decoded as U+FFFD
|
||||||
|
nameBytes[3] = 0x54; // 'T'
|
||||||
|
nameBytes[4] = 0x68; // 'h'
|
||||||
|
nameBytes[5] = 0x65; // 'e'
|
||||||
|
nameBytes[6] = 0x72; // 'r'
|
||||||
|
nameBytes[7] = 0x65; // 'e'
|
||||||
|
// rest are 0x00 (null terminator)
|
||||||
|
final writer = BytesBuilder();
|
||||||
|
writer.addByte(respCodeContact);
|
||||||
|
writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1)));
|
||||||
|
writer.addByte(1); // type
|
||||||
|
writer.addByte(0); // flags
|
||||||
|
writer.addByte(0); // pathLen
|
||||||
|
writer.add(Uint8List(64)); // path
|
||||||
|
writer.add(nameBytes);
|
||||||
|
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp
|
||||||
|
writer.add(Uint8List(4)); // lat
|
||||||
|
writer.add(Uint8List(4)); // lon
|
||||||
|
final frame = Uint8List.fromList(writer.toBytes());
|
||||||
|
final contact = Contact.fromFrame(frame);
|
||||||
|
expect(contact, isNotNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PathRecord — routeWeight field', () {
|
||||||
|
test('default routeWeight is 1.0', () {
|
||||||
|
final record = PathRecord(
|
||||||
|
hopCount: 2,
|
||||||
|
tripTimeMs: 500,
|
||||||
|
timestamp: DateTime(2024),
|
||||||
|
wasFloodDiscovery: false,
|
||||||
|
pathBytes: [0x01, 0x02],
|
||||||
|
successCount: 1,
|
||||||
|
failureCount: 0,
|
||||||
|
);
|
||||||
|
expect(record.routeWeight, equals(1.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom routeWeight is preserved', () {
|
||||||
|
final record = PathRecord(
|
||||||
|
hopCount: 3,
|
||||||
|
tripTimeMs: 800,
|
||||||
|
timestamp: DateTime(2024),
|
||||||
|
wasFloodDiscovery: false,
|
||||||
|
pathBytes: [0x01],
|
||||||
|
successCount: 5,
|
||||||
|
failureCount: 2,
|
||||||
|
routeWeight: 3.5,
|
||||||
|
);
|
||||||
|
expect(record.routeWeight, equals(3.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toJson includes route_weight', () {
|
||||||
|
final record = PathRecord(
|
||||||
|
hopCount: 1,
|
||||||
|
tripTimeMs: 200,
|
||||||
|
timestamp: DateTime(2024),
|
||||||
|
wasFloodDiscovery: true,
|
||||||
|
pathBytes: [],
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
routeWeight: 2.25,
|
||||||
|
);
|
||||||
|
final json = record.toJson();
|
||||||
|
expect(json.containsKey('route_weight'), isTrue);
|
||||||
|
expect(json['route_weight'], equals(2.25));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromJson reads route_weight', () {
|
||||||
|
final json = {
|
||||||
|
'hop_count': 2,
|
||||||
|
'trip_time_ms': 400,
|
||||||
|
'timestamp': DateTime(2024).toIso8601String(),
|
||||||
|
'was_flood': false,
|
||||||
|
'path_bytes': [1, 2, 3],
|
||||||
|
'success_count': 3,
|
||||||
|
'failure_count': 1,
|
||||||
|
'route_weight': 4.0,
|
||||||
|
};
|
||||||
|
final record = PathRecord.fromJson(json);
|
||||||
|
expect(record.routeWeight, equals(4.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'fromJson with missing route_weight defaults to 1.0 (backward compat)',
|
||||||
|
() {
|
||||||
|
final json = {
|
||||||
|
'hop_count': 1,
|
||||||
|
'trip_time_ms': 100,
|
||||||
|
'timestamp': DateTime(2024).toIso8601String(),
|
||||||
|
'was_flood': false,
|
||||||
|
'path_bytes': [],
|
||||||
|
'success_count': 0,
|
||||||
|
'failure_count': 0,
|
||||||
|
// 'route_weight' intentionally omitted
|
||||||
|
};
|
||||||
|
final record = PathRecord.fromJson(json);
|
||||||
|
expect(record.routeWeight, equals(1.0));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AppSettings — new fields', () {
|
||||||
|
test('default values are correct', () {
|
||||||
|
final settings = AppSettings();
|
||||||
|
expect(settings.maxRouteWeight, equals(5.0));
|
||||||
|
expect(settings.initialRouteWeight, equals(3.0));
|
||||||
|
expect(settings.routeWeightSuccessIncrement, equals(0.5));
|
||||||
|
expect(settings.routeWeightFailureDecrement, equals(0.2));
|
||||||
|
expect(settings.maxMessageRetries, equals(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toJson includes all new fields', () {
|
||||||
|
final settings = AppSettings();
|
||||||
|
final json = settings.toJson();
|
||||||
|
expect(json.containsKey('max_route_weight'), isTrue);
|
||||||
|
expect(json.containsKey('initial_route_weight'), isTrue);
|
||||||
|
expect(json.containsKey('route_weight_success_increment'), isTrue);
|
||||||
|
expect(json.containsKey('route_weight_failure_decrement'), isTrue);
|
||||||
|
expect(json.containsKey('max_message_retries'), isTrue);
|
||||||
|
expect(json['max_route_weight'], equals(5.0));
|
||||||
|
expect(json['initial_route_weight'], equals(3.0));
|
||||||
|
expect(json['route_weight_success_increment'], equals(0.5));
|
||||||
|
expect(json['route_weight_failure_decrement'], equals(0.2));
|
||||||
|
expect(json['max_message_retries'], equals(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromJson reads all new fields', () {
|
||||||
|
final json = {
|
||||||
|
'max_route_weight': 10.0,
|
||||||
|
'initial_route_weight': 2.0,
|
||||||
|
'route_weight_success_increment': 1.0,
|
||||||
|
'route_weight_failure_decrement': 1.5,
|
||||||
|
'max_message_retries': 8,
|
||||||
|
};
|
||||||
|
final settings = AppSettings.fromJson(json);
|
||||||
|
expect(settings.maxRouteWeight, equals(10.0));
|
||||||
|
expect(settings.initialRouteWeight, equals(2.0));
|
||||||
|
expect(settings.routeWeightSuccessIncrement, equals(1.0));
|
||||||
|
expect(settings.routeWeightFailureDecrement, equals(1.5));
|
||||||
|
expect(settings.maxMessageRetries, equals(8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'fromJson with missing new fields uses defaults (backward compat)',
|
||||||
|
() {
|
||||||
|
// Simulate an old settings JSON with none of the new fields
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
final settings = AppSettings.fromJson(json);
|
||||||
|
expect(settings.maxRouteWeight, equals(5.0));
|
||||||
|
expect(settings.initialRouteWeight, equals(3.0));
|
||||||
|
expect(settings.routeWeightSuccessIncrement, equals(0.5));
|
||||||
|
expect(settings.routeWeightFailureDecrement, equals(0.2));
|
||||||
|
expect(settings.maxMessageRetries, equals(5));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('copyWith works for maxRouteWeight', () {
|
||||||
|
final settings = AppSettings();
|
||||||
|
final updated = settings.copyWith(maxRouteWeight: 8.0);
|
||||||
|
expect(updated.maxRouteWeight, equals(8.0));
|
||||||
|
// Other fields should be unchanged
|
||||||
|
expect(updated.initialRouteWeight, equals(settings.initialRouteWeight));
|
||||||
|
expect(updated.maxMessageRetries, equals(settings.maxMessageRetries));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith works for initialRouteWeight', () {
|
||||||
|
final settings = AppSettings();
|
||||||
|
final updated = settings.copyWith(initialRouteWeight: 3.0);
|
||||||
|
expect(updated.initialRouteWeight, equals(3.0));
|
||||||
|
expect(updated.maxRouteWeight, equals(settings.maxRouteWeight));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith works for routeWeightSuccessIncrement', () {
|
||||||
|
final settings = AppSettings();
|
||||||
|
final updated = settings.copyWith(routeWeightSuccessIncrement: 0.25);
|
||||||
|
expect(updated.routeWeightSuccessIncrement, equals(0.25));
|
||||||
|
expect(
|
||||||
|
updated.routeWeightFailureDecrement,
|
||||||
|
equals(settings.routeWeightFailureDecrement),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith works for routeWeightFailureDecrement', () {
|
||||||
|
final settings = AppSettings();
|
||||||
|
final updated = settings.copyWith(routeWeightFailureDecrement: 0.75);
|
||||||
|
expect(updated.routeWeightFailureDecrement, equals(0.75));
|
||||||
|
expect(
|
||||||
|
updated.routeWeightSuccessIncrement,
|
||||||
|
equals(settings.routeWeightSuccessIncrement),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith works for maxMessageRetries', () {
|
||||||
|
final settings = AppSettings();
|
||||||
|
final updated = settings.copyWith(maxMessageRetries: 10);
|
||||||
|
expect(updated.maxMessageRetries, equals(10));
|
||||||
|
expect(updated.maxRouteWeight, equals(settings.maxRouteWeight));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user