mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-15 07:04:26 +10:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdd7fc0cdd | |||
| 5ea044af10 | |||
| 9d20be1c06 | |||
| 9436c2d45a | |||
| 17e55e96bb | |||
| e4cfbb57b4 | |||
| d9f9ff58b4 | |||
| a059f1be45 | |||
| 9e46f8b44c | |||
| a934781009 | |||
| 5fe6738f25 | |||
| c1bcf261d7 | |||
| b570539a2d | |||
| a14833494e | |||
| 457b44de3a | |||
| 36d4a10396 | |||
| 77566b0fe1 | |||
| 10b63e0df2 | |||
| ba6d751346 | |||
| 96d222a580 | |||
| 01ad8471cc | |||
| 2b826757cb | |||
| 9bf649e2c6 | |||
| c7a2bf9a95 | |||
| 82adbd761b | |||
| 9a8bdf00dc | |||
| 8b30342113 | |||
| 817c60a155 | |||
| f08e86cf97 | |||
| a6bb9490a1 | |||
| d1e45fc2ba | |||
| 32fa96431e | |||
| 1e9508d401 | |||
| 36697c6e61 | |||
| c9145c99d3 | |||
| 6b6d9caeeb | |||
| d0e3767db6 | |||
| f9cb0c80a5 | |||
| a26d14bd46 | |||
| 411cd3f8d2 | |||
| 38f4de80b6 | |||
| 7de07c023f | |||
| c272c60f9a | |||
| eca78453d6 | |||
| 3754cf14ea | |||
| 834850fb51 | |||
| e7e2bb91b8 | |||
| 4c492f69ef | |||
| 50f2a8b439 | |||
| 2c8a15538e | |||
| 68eeefa04e | |||
| ebbc367fec | |||
| 2da8995d0b | |||
| 1c376b0056 | |||
| da70d5fc08 | |||
| f63bc4b787 | |||
| 9b1f1e1994 | |||
| 5f475fce4d | |||
| 0228c38621 | |||
| fc7283f076 | |||
| 7eff1df6e2 | |||
| 58252b8a40 | |||
| 630606acdc | |||
| bd030153c1 | |||
| 5140ff383d | |||
| dc57f9b9c0 | |||
| 53cd3f4461 | |||
| 35e296f1cd | |||
| 532401cc94 | |||
| 5321974cbb | |||
| 7c16dde989 | |||
| 9a75c912af | |||
| 767dc1164e | |||
| 14f3429eb5 | |||
| e49e80d330 | |||
| d07372c7e0 | |||
| 990f2bd33d | |||
| 29660d520e | |||
| dbefb0b5f4 | |||
| 4f609f160f | |||
| e313bea3fc | |||
| 77be2b8e6f | |||
| c81c3efe7c | |||
| cac0cc15eb | |||
| 1392c2d00f | |||
| cb63b48b78 | |||
| 4ad4a93a20 | |||
| 4962a48e64 | |||
| b88e5e647a | |||
| 87d11c2e6b | |||
| 7b3c099736 | |||
| 11cb14a925 | |||
| d2df2b0bed | |||
| 723bf7293c | |||
| 53caec3e14 | |||
| 3c440ca3d4 | |||
| 8797d8ffde | |||
| faba120823 | |||
| be690c8194 | |||
| 0ef2194fb0 | |||
| 3664ae34cd |
+5
-1
@@ -33,6 +33,9 @@ migrate_working_dir/
|
||||
pubspec.lock
|
||||
/build/
|
||||
/coverage/
|
||||
# fvm project files
|
||||
.fvm/
|
||||
.fvmrc
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
@@ -58,6 +61,7 @@ secrets.dart
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
macos/Flutter/GeneratedPluginRegistrant.swift
|
||||
|
||||
# iOS
|
||||
**/ios/Pods/
|
||||
@@ -85,4 +89,4 @@ keystore.properties
|
||||
.vscode/settings.json
|
||||
|
||||
# Cloudflare Wrangler
|
||||
.wrangler
|
||||
.wrangler
|
||||
|
||||
@@ -51,7 +51,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
|
||||
### 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
|
||||
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
|
||||
- **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
|
||||
|
||||
- ✅ **Android**: Full support (API 21+)
|
||||
- ✅ **iOS**: Full support (iOS 12+)
|
||||
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
|
||||
- 🚧 **Web**: Under construction (Chrome)
|
||||
| Feature | Android (API 21+) | iOS (12+) | Linux | Windows | macOS | Web |
|
||||
|--------------------|:-----------------:|:---------:|:-----:|:-------:|:-----:|:---------------------------------:|
|
||||
| BLE companion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| USB companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ❌<br>(requires websocket bridge) |
|
||||
| Core Functionality | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mesh Network | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Map & Location | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Device Management | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Repeater Hub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
### Dependencies
|
||||
|
||||
@@ -189,6 +195,7 @@ Messages are transmitted as binary frames using a custom protocol optimized for
|
||||
### App Settings
|
||||
|
||||
- **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
|
||||
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
|
||||
- **Message Retry**: Automatic retry with configurable path clearing
|
||||
|
||||
@@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
android {
|
||||
namespace = "com.meshcore.meshcore_open"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
compileOptions {
|
||||
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.
|
||||
+1483
-393
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||
class BufferReader {
|
||||
int _pointer = 0;
|
||||
@@ -37,16 +39,6 @@ class BufferReader {
|
||||
|
||||
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) {
|
||||
_lastPointer = _pointer;
|
||||
final value = <int>[];
|
||||
@@ -62,11 +54,12 @@ class BufferReader {
|
||||
}
|
||||
}
|
||||
|
||||
String readCString(int maxLength) {
|
||||
String readCString({int maxLength = -1}) {
|
||||
final backupPointer = _pointer;
|
||||
final value = <int>[];
|
||||
int counter = 0;
|
||||
while (counter < maxLength) {
|
||||
final maxLen = maxLength >= 0 ? maxLength : remaining;
|
||||
while (counter < maxLen) {
|
||||
final byte = readByte();
|
||||
if (byte == 0) break;
|
||||
value.add(byte);
|
||||
@@ -209,17 +202,20 @@ const int cmdGetChannel = 31;
|
||||
const int cmdSetChannel = 32;
|
||||
const int cmdSendTracePath = 36;
|
||||
const int cmdSetOtherParams = 38;
|
||||
const int cmdSendAnonReq = 57;
|
||||
const int cmdGetTelemetryReq = 39;
|
||||
const int cmdSendTelemetryReq = 39;
|
||||
const int cmdGetCustomVar = 40;
|
||||
const int cmdSetCustomVar = 41;
|
||||
const int cmdSendBinaryReq = 50;
|
||||
const int cmdGetStats = 56;
|
||||
const int cmdSendAnonReq = 57;
|
||||
const int cmdSetAutoAddConfig = 58;
|
||||
const int cmdGetAutoAddConfig = 59;
|
||||
const int cmdSetPathHashMode = 61;
|
||||
|
||||
// Text message types
|
||||
const int txtTypePlain = 0;
|
||||
const int txtTypeCliData = 1;
|
||||
const int txtTypeSigned = 2;
|
||||
|
||||
// Repeater request types (for server requests)
|
||||
const int reqTypeGetStatus = 0x01;
|
||||
@@ -251,6 +247,11 @@ const int respCodeChannelMsgRecvV3 = 17;
|
||||
const int respCodeChannelInfo = 18;
|
||||
const int respCodeCustomVars = 21;
|
||||
const int respCodeAutoAddConfig = 25;
|
||||
const int respCodeStats = 24;
|
||||
|
||||
const int statsTypeCore = 0;
|
||||
const int statsTypeRadio = 1;
|
||||
const int statsTypePackets = 2;
|
||||
|
||||
// Push codes (async from device)
|
||||
const int pushCodeAdvert = 0x80;
|
||||
@@ -272,6 +273,10 @@ const int advTypeRepeater = 2;
|
||||
const int advTypeRoom = 3;
|
||||
const int advTypeSensor = 4;
|
||||
|
||||
const int teleModeDeny = 0;
|
||||
const int teleModeAllowFlags = 1; // use contact.flags
|
||||
const int teleModeAllowAll = 2;
|
||||
|
||||
// Payload Types
|
||||
const int payloadTypeREQ =
|
||||
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||||
@@ -310,6 +315,7 @@ const int autoAddSensorFlag =
|
||||
|
||||
// Sizes
|
||||
const int pubKeySize = 32;
|
||||
const int signatureSize = 64;
|
||||
const int maxPathSize = 64;
|
||||
const int pathHashSize = 1;
|
||||
const int maxNameSize = 32;
|
||||
@@ -352,6 +358,9 @@ const int contactPubKeyOffset = 1;
|
||||
const int contactTypeOffset = 33;
|
||||
const int contactFlagsOffset = 34;
|
||||
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 contactPathOffset = 36;
|
||||
const int contactNameOffset = 100;
|
||||
@@ -370,52 +379,44 @@ const int msgTextOffset = 38;
|
||||
class ParsedContactText {
|
||||
final Uint8List senderPrefix;
|
||||
final String text;
|
||||
|
||||
const ParsedContactText({required this.senderPrefix, required this.text});
|
||||
}
|
||||
|
||||
ParsedContactText? parseContactMessageText(Uint8List frame) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -438,18 +439,9 @@ int readInt32LE(Uint8List data, int offset) {
|
||||
return val;
|
||||
}
|
||||
|
||||
// Helper to read null-terminated UTF-8 string
|
||||
String readCString(Uint8List data, int offset, int maxLen) {
|
||||
int end = offset;
|
||||
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 uint32 to hex string
|
||||
String ackHashToHex(int ackHash) {
|
||||
return ackHash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
// Helper to convert public key to hex string
|
||||
@@ -509,7 +501,7 @@ Uint8List buildSendTextMsgFrame(
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTxtMsg);
|
||||
writer.writeByte(txtTypePlain);
|
||||
writer.writeByte(attempt.clamp(0, 3));
|
||||
writer.writeByte(attempt.clamp(0, 255));
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writer.writeBytes(recipientPubKey.sublist(0, 6));
|
||||
writer.writeString(text);
|
||||
@@ -569,6 +561,17 @@ Uint8List buildGetBattAndStorageFrame() {
|
||||
return Uint8List.fromList([cmdGetBattAndStorage]);
|
||||
}
|
||||
|
||||
/// Companion radio stats: [56][statsType] where statsType is statsTypeCore/Radio/Packets.
|
||||
Uint8List buildGetStatsFrame(int statsType) {
|
||||
return Uint8List.fromList([cmdGetStats, statsType & 0xFF]);
|
||||
}
|
||||
|
||||
/// Path hash width on air: [61][0][mode], mode 0..2 → (mode+1) bytes per hop hash.
|
||||
Uint8List buildSetPathHashModeFrame(int mode) {
|
||||
final m = mode.clamp(0, 2);
|
||||
return Uint8List.fromList([cmdSetPathHashMode, 0, m]);
|
||||
}
|
||||
|
||||
// Build CMD_SET_DEVICE_TIME frame
|
||||
Uint8List buildSetDeviceTimeFrame(int timestamp) {
|
||||
final writer = BufferWriter();
|
||||
@@ -838,7 +841,7 @@ Uint8List buildSendCliCommandFrame(
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTxtMsg);
|
||||
writer.writeByte(txtTypeCliData);
|
||||
writer.writeByte(attempt.clamp(0, 3));
|
||||
writer.writeByte(attempt.clamp(0, 255));
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writer.writeBytes(repeaterPubKey.sublist(0, 6));
|
||||
writer.writeString(command);
|
||||
@@ -937,3 +940,18 @@ Uint8List buildSetAutoAddConfigFrame({
|
||||
writer.writeByte(flags);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
class MeshCoreUuids {
|
||||
static const String service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
static const List<String> deviceNamePrefixes = [
|
||||
"MeshCore-",
|
||||
"Whisper-",
|
||||
"WisCore-",
|
||||
"HT-",
|
||||
];
|
||||
}
|
||||
@@ -1,8 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
|
||||
class LinkHandler {
|
||||
static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final orange = brightness == Brightness.dark
|
||||
? const Color(0xFFFFB74D)
|
||||
: const Color(0xFFE65100);
|
||||
return base.copyWith(color: orange, decoration: TextDecoration.underline);
|
||||
}
|
||||
|
||||
/// 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 ?? defaultLinkStyle(context, style);
|
||||
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
|
||||
const linkifiers = [UrlLinkifier(), EmailLinkifier()];
|
||||
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 {
|
||||
// Show confirmation dialog
|
||||
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 {
|
||||
/// 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;
|
||||
|
||||
/// Combined list of all reaction emojis in fixed order.
|
||||
|
||||
+174
-2
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||
"map_setAsMyLocation": "Задайте като моя местоположение"
|
||||
}
|
||||
"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": "Режим на телеметрията е обновен",
|
||||
"map_showOverlaps": "Покриване на ключа на повтаряча",
|
||||
"map_runTraceWithReturnPath": "Върни се по същия път.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Моля, изчакайте малко, преди да изпратите отново.",
|
||||
"appSettings_languageHu": "Унгарски",
|
||||
"appSettings_jumpToOldestUnread": "Преминете към най-старата непочетена статия",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Когато отворите чат с непрочетени съобщения, плъзнете надолу, за да видите първото непрочетено съобщение, вместо най-новото.",
|
||||
"appSettings_languageJa": "Японски",
|
||||
"appSettings_languageKo": "Корейски",
|
||||
"radioStats_tooltip": "Статистика за радио и мрежа",
|
||||
"radioStats_screenTitle": "Статистически данни за радиопредаванията",
|
||||
"radioStats_notConnected": "Свържете се с устройство, за да видите статистически данни за радиопредаване.",
|
||||
"radioStats_firmwareTooOld": "Статистиката на радиостанцията изисква съвместимо софтуерно решение версия 8 или по-нова.",
|
||||
"radioStats_waiting": "Изчакване на данни…",
|
||||
"radioStats_noiseFloor": "Ниво на шума: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Последен RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Последна стойност на SNR: {snr} dB",
|
||||
"radioStats_txAir": "Време на въздух (общо): {seconds} секунди",
|
||||
"radioStats_rxAir": "Общо време на използване на RX (в секунди): {seconds} с",
|
||||
"radioStats_chartCaption": "Ниво на шума (dBm) за последните измервания.",
|
||||
"radioStats_stripNoise": "Ниво на шума: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Извличане на данни за радиото…",
|
||||
"radioStats_settingsTile": "Статистически данни за радиостанции",
|
||||
"radioStats_settingsSubtitle": "Ниво на шума, RSSI, SNR и време на пренос",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_enableTitle": "Активирайте превода",
|
||||
"translation_title": "Превод",
|
||||
"translation_composerTitle": "Преведете преди да изпратите",
|
||||
"translation_enableSubtitle": "Превеждайте входящите съобщения и позволявайте предварително превеждане преди изпращане.",
|
||||
"translation_composerSubtitle": "Контролира началния статус на иконата за превод, създадена от композитора.",
|
||||
"translation_targetLanguage": "Целеви език",
|
||||
"translation_useAppLanguage": "Използвайте езика на приложението",
|
||||
"translation_downloadedModelLabel": "Изтегнат модел",
|
||||
"translation_presetModelLabel": "Предварително конфигуриран модел от Hugging Face",
|
||||
"translation_manualUrlLabel": "URL на ръководството",
|
||||
"translation_downloadModel": "Изтеглете модела",
|
||||
"translation_downloading": "Изтегляне...",
|
||||
"translation_working": "Работа...",
|
||||
"translation_stop": "Спрете",
|
||||
"translation_mergingChunks": "Съединяване на изтеглените части в един файл...",
|
||||
"translation_downloadedModels": "Изтеглени модели",
|
||||
"translation_deleteModel": "Изтриване на модела",
|
||||
"translation_modelDownloaded": "Моделът за превод е изтеглен.",
|
||||
"translation_downloadStopped": "Изтеглянето беше прекъснато.",
|
||||
"translation_downloadFailed": "Не успях да изтегля: {error}",
|
||||
"translation_enterUrlFirst": "Въведете първо URL адрес на модела.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerDisabledHint": "Изпращайте съобщения на оригиналния въведен език.",
|
||||
"translation_translateBeforeSending": "Преведете преди да изпратите",
|
||||
"translation_messageTranslation": "Превод на съобщението",
|
||||
"translation_composerEnabledHint": "Съобщенията ще бъдат преведени, преди да бъдат изпратени.",
|
||||
"translation_translateTo": "Превеждане на {language}",
|
||||
"translation_translationOptions": "Опции за превод",
|
||||
"translation_systemLanguage": "Език на системата",
|
||||
"scanner_linuxPairingPinTitle": "PIN за съвпадение чрез Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Въведете PIN кода за {deviceName} (оставете празно, ако няма такъв).",
|
||||
"scanner_linuxPairingHidePin": "Скриване на PIN кода",
|
||||
"scanner_linuxPairingShowPin": "Покажи PIN",
|
||||
"repeater_cliQuickClockSync": "Синхронизация на часовника",
|
||||
"repeater_cliQuickDiscovery": "Открий Съседи"
|
||||
}
|
||||
+174
-2
@@ -1917,5 +1917,177 @@
|
||||
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
||||
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
||||
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
|
||||
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Überlappungen der Repeater-Taste",
|
||||
"map_runTraceWithReturnPath": "Auf dem gleichen Pfad zurückkehren.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Bitte warten Sie einen Moment, bevor Sie erneut senden.",
|
||||
"appSettings_jumpToOldestUnread": "Zum ältesten, nicht gelesenen Eintrag springen",
|
||||
"appSettings_languageHu": "Ungarisch",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Wenn Sie ein Chatfenster öffnen, in dem Nachrichten vorhanden sind, die noch nicht gelesen wurden, scrollen Sie zu der ersten unlesenen Nachricht, anstatt zur neuesten.",
|
||||
"appSettings_languageJa": "Japanisch",
|
||||
"appSettings_languageKo": "Koreanisch",
|
||||
"radioStats_tooltip": "Daten zu Radio- und Mesh-Netzwerken",
|
||||
"radioStats_screenTitle": "Senderinformationen",
|
||||
"radioStats_notConnected": "Verbinden Sie ein Gerät, um Radiostatisiken anzuzeigen.",
|
||||
"radioStats_firmwareTooOld": "Für die Verwendung der Funkstatistiken ist die Firmware-Version 8 oder höher erforderlich.",
|
||||
"radioStats_waiting": "Warte auf Daten…",
|
||||
"radioStats_noiseFloor": "Rauschpegel: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Letzter RSSI-Wert: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Letzter SNR: {snr} dB",
|
||||
"radioStats_txAir": "Gesamt-TX-Zeit: {seconds} s",
|
||||
"radioStats_rxAir": "Gesamt-RX-Zeit: {seconds} s",
|
||||
"radioStats_chartCaption": "Rauschpegel (dBm) basierend auf den letzten Messwerten.",
|
||||
"radioStats_stripNoise": "Rauschpegel: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Abrufen von Radiostatus…",
|
||||
"radioStats_settingsTile": "Senderinformationen",
|
||||
"radioStats_settingsSubtitle": "Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_title": "Übersetzung",
|
||||
"translation_composerTitle": "Übersetzen Sie vor dem Versenden",
|
||||
"translation_enableSubtitle": "Nachrichten empfangen und übersetzen sowie die Möglichkeit bieten, Nachrichten vor dem Versenden zu übersetzen.",
|
||||
"translation_enableTitle": "Aktivieren Sie die Übersetzung",
|
||||
"translation_composerSubtitle": "Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.",
|
||||
"translation_targetLanguage": "Zielsprache",
|
||||
"translation_useAppLanguage": "Verwenden Sie die App-Sprache",
|
||||
"translation_downloadedModelLabel": "Heruntergeladenes Modell",
|
||||
"translation_presetModelLabel": "Vordefinierter Hugging Face-Modell",
|
||||
"translation_manualUrlLabel": "URL für das manuelle Modell",
|
||||
"translation_downloadModel": "Modell herunterladen",
|
||||
"translation_downloading": "Herunterladen...",
|
||||
"translation_working": "Arbeiten...",
|
||||
"translation_stop": "Stopp",
|
||||
"translation_mergingChunks": "Zusammenführen der heruntergeladenen Teile in die finale Datei...",
|
||||
"translation_downloadedModels": "Heruntergeladene Modelle",
|
||||
"translation_deleteModel": "Modell löschen",
|
||||
"translation_modelDownloaded": "Übersetzungsmotor heruntergeladen.",
|
||||
"translation_downloadStopped": "Herunterladen abgebrochen.",
|
||||
"translation_downloadFailed": "Download fehlgeschlagen: {error}",
|
||||
"translation_enterUrlFirst": "Geben Sie zunächst die URL eines Modells ein.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_messageTranslation": "Nachricht übersetzen",
|
||||
"translation_composerEnabledHint": "Die Nachrichten werden vor dem Versenden übersetzt.",
|
||||
"translation_translateBeforeSending": "Übersetzen Sie vor dem Versenden",
|
||||
"translation_composerDisabledHint": "Nachrichten in der ursprünglichen, getippten Sprache senden.",
|
||||
"translation_translateTo": "Übersetzen Sie auf {language}",
|
||||
"translation_translationOptions": "Übersetzungsmöglichkeiten",
|
||||
"translation_systemLanguage": "Sprache des Systems",
|
||||
"scanner_linuxPairingShowPin": "PIN anzeigen",
|
||||
"scanner_linuxPairingHidePin": "PIN ausblenden",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth-Paarungs-PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine).",
|
||||
"repeater_cliQuickClockSync": "Uhr Synchronisieren",
|
||||
"repeater_cliQuickDiscovery": "Entdecke Nachbarn"
|
||||
}
|
||||
+185
-2
@@ -127,6 +127,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"scanner_stop": "Stop",
|
||||
"scanner_scan": "Scan",
|
||||
"scanner_bluetoothOff": "Bluetooth is off",
|
||||
@@ -166,6 +167,26 @@
|
||||
"settings_privacyModeToggle": "Toggle privacy mode to hide your name and location in advertisements.",
|
||||
"settings_privacyModeEnabled": "Privacy mode enabled",
|
||||
"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_sendAdvertisement": "Send Advertisement",
|
||||
"settings_sendAdvertisementSubtitle": "Broadcast presence now",
|
||||
@@ -269,6 +290,27 @@
|
||||
"appSettings_autoRouteRotationSubtitle": "Cycle between best paths and flood mode",
|
||||
"appSettings_autoRouteRotationEnabled": "Auto route rotation enabled",
|
||||
"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_batteryChemistry": "Battery Chemistry",
|
||||
"appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})",
|
||||
@@ -455,6 +497,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_noChannelsConfigured": "No channels configured",
|
||||
"channels_addPublicChannel": "Add Public Channel",
|
||||
@@ -554,6 +607,15 @@
|
||||
"channels_enterHashtag": "Enter hashtag",
|
||||
"channels_hashtagHint": "e.g. #team",
|
||||
"chat_noMessages": "No messages yet",
|
||||
"chat_sendMessage": "Send message",
|
||||
"chat_sendMessageTo": "Send message to {name}",
|
||||
"@chat_sendMessageTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendMessageToStart": "Send a message to get started",
|
||||
"chat_originalMessageNotFound": "Original message not found",
|
||||
"chat_replyingTo": "Replying to {name}",
|
||||
@@ -830,6 +892,7 @@
|
||||
"map_chatNodes": "Chat Nodes",
|
||||
"map_repeaters": "Repeaters",
|
||||
"map_otherNodes": "Other Nodes",
|
||||
"map_showOverlaps": "Repeater Key Overlaps",
|
||||
"map_keyPrefix": "Key Prefix",
|
||||
"map_filterByKeyPrefix": "Filter by key prefix",
|
||||
"map_publicKeyPrefix": "Public key prefix",
|
||||
@@ -843,7 +906,8 @@
|
||||
"map_joinRoom": "Join Room",
|
||||
"map_manageRepeater": "Manage Repeater",
|
||||
"map_tapToAdd": "Tap on nodes to add them to the path.",
|
||||
"map_runTrace": "Run Path Trace",
|
||||
"map_runTrace": "Run path trace",
|
||||
"map_runTraceWithReturnPath": "Return back on the same path.",
|
||||
"map_removeLast": "Remove Last",
|
||||
"map_pathTraceCancelled": "Path trace cancelled.",
|
||||
"mapCache_title": "Offline Map Cache",
|
||||
@@ -1282,6 +1346,8 @@
|
||||
"repeater_cliQuickVersion": "Version",
|
||||
"repeater_cliQuickAdvertise": "Advertise",
|
||||
"repeater_cliQuickClock": "Clock",
|
||||
"repeater_cliQuickClockSync": "Clock Sync",
|
||||
"repeater_cliQuickDiscovery": "Discover Neighbors",
|
||||
"repeater_cliHelpAdvert": "Sends an advertisement packet",
|
||||
"repeater_cliHelpReboot": "Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
|
||||
"repeater_cliHelpClock": "Displays current time per device's clock.",
|
||||
@@ -1927,5 +1993,122 @@
|
||||
"discoveredContacts_copyContact": "Copy Contact to clipboard",
|
||||
"discoveredContacts_deleteContact": "Delete Discovered Contact",
|
||||
"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?",
|
||||
"chat_sendCooldown": "Please wait a moment before sending again.",
|
||||
"appSettings_jumpToOldestUnread": "Jump to oldest unread",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "When opening a chat with unread messages, scroll to the first unread instead of the latest.",
|
||||
"appSettings_languageHu": "Hungarian",
|
||||
"appSettings_languageJa": "Japanese",
|
||||
"appSettings_languageKo": "Korean",
|
||||
"radioStats_tooltip": "Radio & mesh stats",
|
||||
"radioStats_screenTitle": "Radio stats",
|
||||
"radioStats_notConnected": "Connect to a device to view radio statistics.",
|
||||
"radioStats_firmwareTooOld": "Radio statistics require companion firmware v8 or newer.",
|
||||
"radioStats_waiting": "Waiting for data…",
|
||||
"radioStats_noiseFloor": "Noise floor: {noiseDbm} dBm",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_lastRssi": "Last RSSI: {rssiDbm} dBm",
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_lastSnr": "Last SNR: {snr} dB",
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_txAir": "TX airtime (total): {seconds} s",
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_rxAir": "RX airtime (total): {seconds} s",
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_chartCaption": "Noise floor (dBm) over recent samples.",
|
||||
"radioStats_stripNoise": "Noise floor: {noiseDbm} dBm",
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_stripWaiting": "Fetching radio stats…",
|
||||
"radioStats_settingsTile": "Radio stats",
|
||||
"radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime",
|
||||
|
||||
"translation_title": "Translation",
|
||||
"translation_enableTitle": "Enable translation",
|
||||
"translation_enableSubtitle": "Translate incoming messages and allow pre-send translation.",
|
||||
"translation_composerTitle": "Translate before sending",
|
||||
"translation_composerSubtitle": "Controls the default state of the composer translation icon.",
|
||||
"translation_targetLanguage": "Target language",
|
||||
"translation_useAppLanguage": "Use app language",
|
||||
"translation_downloadedModelLabel": "Downloaded model",
|
||||
"translation_presetModelLabel": "Preset Hugging Face model",
|
||||
"translation_manualUrlLabel": "Manual model URL",
|
||||
"translation_downloadModel": "Download model",
|
||||
"translation_downloading": "Downloading...",
|
||||
"translation_working": "Working...",
|
||||
"translation_stop": "Stop",
|
||||
"translation_mergingChunks": "Merging downloaded chunks into final file...",
|
||||
"translation_downloadedModels": "Downloaded models",
|
||||
"translation_deleteModel": "Delete model",
|
||||
"translation_modelDownloaded": "Translation model downloaded.",
|
||||
"translation_downloadStopped": "Download stopped.",
|
||||
"translation_downloadFailed": "Download failed: {error}",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_enterUrlFirst": "Enter a model URL first.",
|
||||
"scanner_linuxPairingShowPin": "Show PIN",
|
||||
"scanner_linuxPairingHidePin": "Hide PIN",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth Pairing PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Enter PIN for {deviceName} (leave blank if none).",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_messageTranslation": "Message translation",
|
||||
"translation_translateBeforeSending": "Translate before sending",
|
||||
"translation_composerEnabledHint": "Messages will be translated before send.",
|
||||
"translation_composerDisabledHint": "Send messages in the original typed language.",
|
||||
"translation_translateTo": "Translate to {language}",
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_translationOptions": "Translation options",
|
||||
"translation_systemLanguage": "System language"
|
||||
}
|
||||
|
||||
+174
-2
@@ -1917,5 +1917,177 @@
|
||||
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
||||
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
|
||||
"map_setAsMyLocation": "Establecer mi ubicación"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Superposiciones de tecla repetidora",
|
||||
"map_runTraceWithReturnPath": "Volver atrás por el mismo camino.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnread": "Salta a los mensajes más antiguos sin leer",
|
||||
"chat_sendCooldown": "Por favor, espere un momento antes de reenviar.",
|
||||
"appSettings_languageHu": "Húngaro",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Cuando abras una conversación con mensajes sin leer, desplázate hacia el primer mensaje sin leer en lugar del más reciente.",
|
||||
"appSettings_languageJa": "Japonés",
|
||||
"appSettings_languageKo": "Coreano",
|
||||
"radioStats_tooltip": "Estadísticas de radio y malla",
|
||||
"radioStats_screenTitle": "Estadísticas de radio",
|
||||
"radioStats_notConnected": "Conéctese a un dispositivo para visualizar estadísticas de radio.",
|
||||
"radioStats_firmwareTooOld": "Las estadísticas de radio requieren un firmware compatible v8 o posterior.",
|
||||
"radioStats_waiting": "Esperando datos…",
|
||||
"radioStats_noiseFloor": "Nivel de ruido: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Último RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Último SNR: {snr} dB",
|
||||
"radioStats_txAir": "Tiempo de emisión en Texas (total): {seconds} s",
|
||||
"radioStats_rxAir": "Tiempo de transmisión de RX (total): {seconds} s",
|
||||
"radioStats_chartCaption": "Nivel de ruido (dBm) en muestras recientes.",
|
||||
"radioStats_stripNoise": "Nivel de ruido: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Obteniendo estadísticas de la radio…",
|
||||
"radioStats_settingsTile": "Estadísticas de radio",
|
||||
"radioStats_settingsSubtitle": "Nivel de ruido, RSSI, SNR y tiempo de transmisión",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_title": "Traducción",
|
||||
"translation_enableSubtitle": "Traducir los mensajes entrantes y permitir la traducción previa al envío.",
|
||||
"translation_enableTitle": "Habilitar la traducción",
|
||||
"translation_composerTitle": "Traducir antes de enviar",
|
||||
"translation_composerSubtitle": "Controla el estado predeterminado del icono de traducción del compositor.",
|
||||
"translation_targetLanguage": "Idioma de destino",
|
||||
"translation_useAppLanguage": "Utilizar el idioma de la aplicación",
|
||||
"translation_downloadedModelLabel": "Modelo descargado",
|
||||
"translation_presetModelLabel": "Modelo predefinido de Hugging Face",
|
||||
"translation_manualUrlLabel": "URL del modelo manual",
|
||||
"translation_downloadModel": "Descargar el modelo",
|
||||
"translation_downloading": "Descargando...",
|
||||
"translation_working": "Trabajando...",
|
||||
"translation_stop": "¡Detente!",
|
||||
"translation_mergingChunks": "Combinando los fragmentos descargados en el archivo final...",
|
||||
"translation_downloadedModels": "Modelos descargados",
|
||||
"translation_deleteModel": "Eliminar modelo",
|
||||
"translation_modelDownloaded": "Modelo de traducción descargado.",
|
||||
"translation_downloadStopped": "La descarga se ha detenido.",
|
||||
"translation_downloadFailed": "No se pudo descargar: {error}",
|
||||
"translation_enterUrlFirst": "Primero, introduzca la URL del modelo.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingPinPrompt": "Introduzca el código PIN para {deviceName} (deje en blanco si no hay ninguno).",
|
||||
"scanner_linuxPairingShowPin": "Mostrar código PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN para emparejar dispositivos Bluetooth",
|
||||
"scanner_linuxPairingHidePin": "Ocultar PIN",
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerDisabledHint": "Envía mensajes utilizando el lenguaje escrito original.",
|
||||
"translation_composerEnabledHint": "Los mensajes serán traducidos antes de ser enviados.",
|
||||
"translation_messageTranslation": "Traducción del mensaje",
|
||||
"translation_translateBeforeSending": "Traducir antes de enviar",
|
||||
"translation_translateTo": "Traducir a {language}",
|
||||
"translation_translationOptions": "Opciones de traducción",
|
||||
"translation_systemLanguage": "Idioma del sistema",
|
||||
"repeater_cliQuickDiscovery": "Descubrir Vecinos",
|
||||
"repeater_cliQuickClockSync": "Sincronización del reloj"
|
||||
}
|
||||
+197
-25
@@ -143,8 +143,8 @@
|
||||
"settings_frequencyHelper": "300,0 - 2 500,0",
|
||||
"settings_frequencyInvalid": "Fréquence invalide (300-2500 MHz)",
|
||||
"settings_bandwidth": "Bande passante",
|
||||
"settings_spreadingFactor": "Facteur de répartition",
|
||||
"settings_codingRate": "Taux de codage",
|
||||
"settings_spreadingFactor": "Facteur de répartition (SF)",
|
||||
"settings_codingRate": "Taux de codage (CR)",
|
||||
"settings_txPower": "TX Puissance (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Puissance TX invalide (0-22 dBm)",
|
||||
@@ -567,7 +567,7 @@
|
||||
"chat_clearPath": "Effacer le chemin",
|
||||
"chat_clearPathSubtitle": "Forcer la redécouverte lors de la prochaine envoi",
|
||||
"chat_pathCleared": "Le chemin est dégagé. Le prochain message redécouvrira le tracé.",
|
||||
"chat_floodModeSubtitle": "Utiliser le commutateur de routage dans la barre d'application",
|
||||
"chat_floodModeSubtitle": "Désactive l'apprentissage du chemin (à éviter). Utiliser le commutateur de routage dans la barre d'application pour rebasculer en mode auto par la suite.",
|
||||
"chat_floodModeEnabled": "Le mode envoi à tout le réseau est activé. Changer via l'icône de routage dans la barre d'outils.",
|
||||
"chat_fullPath": "Chemin complet",
|
||||
"chat_pathDetailsNotAvailable": "Les détails du chemin ne sont pas encore disponibles. Essayez d'envoyer un message pour rafraîchir.",
|
||||
@@ -643,7 +643,7 @@
|
||||
},
|
||||
"map_chat": "Chat",
|
||||
"map_repeater": "Répéteur",
|
||||
"map_room": "Salle",
|
||||
"map_room": "Room Server",
|
||||
"map_sensor": "Capteur",
|
||||
"map_pinDm": "Clé (DM)",
|
||||
"map_pinPrivate": "Verrouiller (Privé)",
|
||||
@@ -682,7 +682,7 @@
|
||||
"map_showSharedMarkers": "Afficher les marqueurs partagés",
|
||||
"map_lastSeenTime": "Dernière fois vu",
|
||||
"map_sharedPin": "Clé partagée",
|
||||
"map_joinRoom": "Rejoindre la salle",
|
||||
"map_joinRoom": "Rejoindre le room server",
|
||||
"map_manageRepeater": "Gérer le répéteur",
|
||||
"mapCache_title": "Cache de Carte Hors Ligne",
|
||||
"mapCache_selectAreaFirst": "Sélectionner une zone pour la mise en cache en premier",
|
||||
@@ -865,7 +865,7 @@
|
||||
"path_labelHexPrefixes": "Préfixes hexadécimaux",
|
||||
"path_helperMaxHops": "Max 64 sauts. Chaque préfixe fait 2 caractères hexadécimaux (1 octet)",
|
||||
"path_selectFromContacts": "Sélectionner à partir des contacts :",
|
||||
"path_noRepeatersFound": "Aucun répéteur ou serveur de salle n'a été trouvé.",
|
||||
"path_noRepeatersFound": "Aucun répéteur ou room server n'a été trouvé.",
|
||||
"path_customPathsRequire": "Les chemins personnalisés nécessitent des sauts intermédiaires qui peuvent transmettre des messages.",
|
||||
"path_invalidHexPrefixes": "Préfixes hexadécimaux invalides : {prefixes}",
|
||||
"@path_invalidHexPrefixes": {
|
||||
@@ -996,15 +996,15 @@
|
||||
"repeater_txPower": "TX Puissance",
|
||||
"repeater_txPowerHelper": "1-30 dBm",
|
||||
"repeater_bandwidth": "Bande passante",
|
||||
"repeater_spreadingFactor": "Facteur de répartition",
|
||||
"repeater_codingRate": "Taux de codage",
|
||||
"repeater_spreadingFactor": "Facteur de répartition (SF)",
|
||||
"repeater_codingRate": "Taux de codage (CR)",
|
||||
"repeater_locationSettings": "Paramètres de localisation",
|
||||
"repeater_latitude": "Latitude",
|
||||
"repeater_latitudeHelper": "Degrés décimaux (par exemple, 37.7749)",
|
||||
"repeater_longitude": "Longitude",
|
||||
"repeater_longitudeHelper": "Degrés décimaux (par exemple, -122,4194)",
|
||||
"repeater_features": "Fonctionnalités",
|
||||
"repeater_packetForwarding": "Transfert de paquets",
|
||||
"repeater_packetForwarding": "Mode répéteur",
|
||||
"repeater_packetForwardingSubtitle": "Activer le répéteur pour transmettre des paquets",
|
||||
"repeater_guestAccess": "Accès Invité",
|
||||
"repeater_guestAccessSubtitle": "Autoriser l'accès invité en lecture seule",
|
||||
@@ -1377,7 +1377,7 @@
|
||||
"channels_joinPublicChannelDesc": "Tout le monde peut rejoindre ce canal.",
|
||||
"channels_joinHashtagChannel": "Rejoindre un Canal Hashtag",
|
||||
"channels_joinHashtagChannelDesc": "N'importe qui peut rejoindre les canaux #hashtag.",
|
||||
"channels_scanQrCode": "Scanner un code QR",
|
||||
"channels_scanQrCode": "Scanner un QR code",
|
||||
"channels_scanQrCodeComingSoon": "Bientôt disponible",
|
||||
"channels_enterHashtag": "Entrez le hashtag",
|
||||
"channels_hashtagHint": "ex. #equipe",
|
||||
@@ -1466,8 +1466,8 @@
|
||||
"community_join": "Rejoindre",
|
||||
"community_joinTitle": "Rejoindre la communauté",
|
||||
"community_joinConfirmation": "Souhaitez-vous rejoindre la communauté \"{name}\" ?",
|
||||
"community_scanQr": "Scanner la communauté QR",
|
||||
"community_scanInstructions": "Pointez l'appareil photo vers un code QR communautaire.",
|
||||
"community_scanQr": "Scanner un QR code de communauté",
|
||||
"community_scanInstructions": "Pointez l'appareil photo vers un QR code de communauté.",
|
||||
"community_showQr": "Afficher le QR Code",
|
||||
"community_publicChannel": "Communauté Publique",
|
||||
"community_hashtagChannel": "Hashtag Communauté",
|
||||
@@ -1478,13 +1478,13 @@
|
||||
"community_qrTitle": "Partager Communauté",
|
||||
"community_qrInstructions": "Scanner ce QR code pour rejoindre {name}",
|
||||
"community_hashtagPrivacyHint": "Les canaux hashtag de la communauté ne sont accessibles qu'aux membres de la communauté",
|
||||
"community_invalidQrCode": "Code QR de communauté non valide",
|
||||
"community_invalidQrCode": "QR code de communauté non valide",
|
||||
"community_alreadyMember": "Déjà membre",
|
||||
"community_alreadyMemberMessage": "Vous êtes déjà membre de \"{name}\".",
|
||||
"community_addPublicChannel": "Ajouter un Canal Public de la Communauté",
|
||||
"community_addPublicChannelHint": "Ajouter automatiquement le canal public pour cette communauté",
|
||||
"community_noCommunities": "Aucun groupe n'a été rejoint pour le moment.",
|
||||
"community_scanOrCreate": "Scanner un code QR ou créer une communauté pour commencer",
|
||||
"community_scanOrCreate": "Scanner un QR code ou créer une communauté pour commencer",
|
||||
"community_manageCommunities": "Gérer les Communautés",
|
||||
"community_delete": "Quitter la communauté",
|
||||
"community_deleteConfirm": "Quitter \"{name}\" ?",
|
||||
@@ -1534,10 +1534,10 @@
|
||||
}
|
||||
},
|
||||
"community_regenerateSecret": "Régénérer le secret",
|
||||
"community_regenerateSecretConfirm": "Régénérer la clé secrète pour \"{name}\" ? Tous les membres devront scanner le nouveau code QR pour continuer à communiquer.",
|
||||
"community_regenerateSecretConfirm": "Régénérer la clé secrète pour \"{name}\" ? Tous les membres devront scanner le nouveau QR code pour continuer à communiquer.",
|
||||
"community_regenerate": "Régénérer",
|
||||
"community_secretRegenerated": "Mot de passe secret régénéré pour \"{name}\"",
|
||||
"community_scanToUpdateSecret": "Scanner le nouveau code QR pour mettre à jour le mot de passe pour \"{name}\"",
|
||||
"community_scanToUpdateSecret": "Scanner le nouveau QR code pour mettre à jour le mot de passe pour \"{name}\"",
|
||||
"community_updateSecret": "Mettre à jour le secret",
|
||||
"community_secretUpdated": "Modification secrète mise à jour pour \"{name}\"",
|
||||
"@contacts_pathTraceTo": {
|
||||
@@ -1554,11 +1554,11 @@
|
||||
"contacts_pathTrace": "Traçage de chemin",
|
||||
"contacts_repeaterPathTrace": "Tracer le chemin vers le répéteur",
|
||||
"contacts_repeaterPing": "Pinguer le répéteur",
|
||||
"contacts_roomPathTrace": "Traçage du chemin vers le serveur de la salle",
|
||||
"contacts_roomPathTrace": "Traçage du chemin vers le room server",
|
||||
"contacts_chatTraceRoute": "Tracer le chemin",
|
||||
"contacts_pathTraceTo": "Tracer l'itinéraire vers {name}",
|
||||
"contacts_ping": "Ping",
|
||||
"contacts_roomPing": "Pinguer le serveur de la salle",
|
||||
"contacts_roomPing": "Pinguer le room server",
|
||||
"contacts_invalidAdvertFormat": "Données de contact non valides",
|
||||
"appSettings_languageUk": "Ukrainien",
|
||||
"appSettings_languageRu": "Russe",
|
||||
@@ -1583,12 +1583,12 @@
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{nouveau nœud} other{nouveaux nœuds}}",
|
||||
"notification_newTypeDiscovered": "Nouveau {contactType} découvert",
|
||||
"notification_receivedNewMessage": "Nouveau message reçu",
|
||||
"settings_gpxExportRepeaters": "Exporter les répéteurs / serveur de salle au format GPX",
|
||||
"settings_gpxExportRepeaters": "Exporter les répéteurs / room servers au format GPX",
|
||||
"settings_gpxExportRepeatersSubtitle": "Exporte les répéteurs / roomserver avec une localisation vers un fichier GPX.",
|
||||
"settings_gpxExportNoContacts": "Aucun contact à exporter.",
|
||||
"settings_gpxExportNotAvailable": "Non pris en charge sur votre appareil/Système d'exploitation",
|
||||
"settings_gpxExportError": "Une erreur s'est produite lors de l'exportation.",
|
||||
"settings_gpxExportRepeatersRoom": "Emplacements des serveurs de répéteur et de salle",
|
||||
"settings_gpxExportRepeatersRoom": "Emplacements des répéteurs et room servers",
|
||||
"settings_gpxExportContacts": "Exporter les compagnons au format GPX",
|
||||
"settings_gpxExportAll": "Exporter tous les contacts au format GPX",
|
||||
"settings_gpxExportAllSubtitle": "Exporte tous les contacts avec une localisation vers un fichier GPX.",
|
||||
@@ -1800,15 +1800,15 @@
|
||||
"contacts_unread": "Non lu",
|
||||
"contacts_searchFavorites": "Rechercher {number}{str} Favoris...",
|
||||
"contacts_searchUsers": "Rechercher {number}{str} utilisateurs...",
|
||||
"contacts_searchRoomServers": "Rechercher {number}{str} serveurs de salle...",
|
||||
"contacts_searchRoomServers": "Rechercher {number}{str} room server...",
|
||||
"contacts_searchRepeaters": "Rechercher {number}{str} Répéteurs...",
|
||||
"contacts_searchContactsNoNumber": "Rechercher des contacts...",
|
||||
"settings_contactSettings": "Paramètres de contact",
|
||||
"settings_contactSettingsSubtitle": "Paramètres pour l'ajout de contacts",
|
||||
"contactsSettings_autoAddRepeatersTitle": "Ajouter automatiquement les répéteurs",
|
||||
"contactsSettings_autoAddRepeatersSubtitle": "Autoriser le compagnon à ajouter automatiquement les répéteurs découverts",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Ajouter automatiquement les serveurs de salle",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Autoriser le compagnon à ajouter automatiquement les serveurs de salles découverts",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Ajouter automatiquement les room servers",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Autoriser le compagnon à ajouter automatiquement les room servers découverts",
|
||||
"contactsSettings_otherTitle": "Autres paramètres liés aux contacts",
|
||||
"contactsSettings_title": "Paramètres des contacts",
|
||||
"contactsSettings_autoAddUsersTitle": "Ajouter automatiquement les utilisateurs",
|
||||
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
||||
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
||||
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
|
||||
"map_setAsMyLocation": "Définir comme ma localisation"
|
||||
}
|
||||
"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",
|
||||
"map_showOverlaps": "Chevauchement de la touche répétitive",
|
||||
"map_runTraceWithReturnPath": "Revenir sur le même chemin.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Veuillez patienter un instant avant de réessayer.",
|
||||
"appSettings_jumpToOldestUnread": "Accéder au message le plus ancien non lu",
|
||||
"appSettings_languageHu": "Hongrois",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Lorsque vous ouvrez une conversation contenant des messages non lus, faites défiler la page jusqu'au premier message non lu, plutôt que jusqu'au dernier.",
|
||||
"appSettings_languageJa": "Japonais",
|
||||
"appSettings_languageKo": "Coréen",
|
||||
"radioStats_tooltip": "Statistiques des radios et des réseaux sans fil",
|
||||
"radioStats_screenTitle": "Statistiques de radio",
|
||||
"radioStats_notConnected": "Connectez-vous à un appareil pour visualiser les statistiques de la radio.",
|
||||
"radioStats_firmwareTooOld": "Les statistiques radio nécessitent un firmware compatible v8 ou une version ultérieure.",
|
||||
"radioStats_waiting": "En attente des données…",
|
||||
"radioStats_noiseFloor": "Niveau de bruit : {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Dernier RSSI : {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Dernier SNR : {snr} dB",
|
||||
"radioStats_txAir": "Temps d'antenne à la télévision du Texas (total) : {seconds} s",
|
||||
"radioStats_rxAir": "Temps d'utilisation de l'appareil RX (total) : {seconds} s",
|
||||
"radioStats_chartCaption": "Niveau de bruit (dBm) sur les échantillons récents.",
|
||||
"radioStats_stripNoise": "Niveau de bruit : {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Récupération des statistiques de la radio…",
|
||||
"radioStats_settingsTile": "Statistiques de radio",
|
||||
"radioStats_settingsSubtitle": "Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d'antenne",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerTitle": "Traduire avant d'envoyer",
|
||||
"translation_enableTitle": "Activer la traduction",
|
||||
"translation_title": "Traduction",
|
||||
"translation_enableSubtitle": "Traduire les messages entrants et permettre la traduction avant l'envoi.",
|
||||
"translation_composerSubtitle": "Contrôle l'état par défaut de l'icône de traduction du composant.",
|
||||
"translation_targetLanguage": "Langue cible",
|
||||
"translation_useAppLanguage": "Utiliser la langue de l'application",
|
||||
"translation_downloadedModelLabel": "Modèle téléchargé",
|
||||
"translation_presetModelLabel": "Modèle Hugging Face préconfiguré",
|
||||
"translation_manualUrlLabel": "URL du modèle manuel",
|
||||
"translation_downloadModel": "Télécharger le modèle",
|
||||
"translation_downloading": "Téléchargement...",
|
||||
"translation_working": "Au travail...",
|
||||
"translation_stop": "Arrêtez",
|
||||
"translation_mergingChunks": "Fusion des fragments téléchargés dans le fichier final...",
|
||||
"translation_downloadedModels": "Modèles téléchargés",
|
||||
"translation_deleteModel": "Supprimer le modèle",
|
||||
"translation_modelDownloaded": "Modèle de traduction téléchargé.",
|
||||
"translation_downloadStopped": "Le téléchargement a été interrompu.",
|
||||
"translation_downloadFailed": "Échec du téléchargement : {error}",
|
||||
"translation_enterUrlFirst": "Entrez d'abord l'URL du modèle.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerEnabledHint": "Les messages seront traduits avant d'être envoyés.",
|
||||
"translation_translateBeforeSending": "Traduire avant d'envoyer",
|
||||
"translation_composerDisabledHint": "Envoyez des messages dans la langue originale, telle que vous l'avez tapée.",
|
||||
"translation_messageTranslation": "Traduction du message",
|
||||
"translation_translateTo": "Traduire en {language}",
|
||||
"translation_translationOptions": "Options de traduction",
|
||||
"translation_systemLanguage": "Langue du système",
|
||||
"scanner_linuxPairingPinTitle": "Code PIN pour la connexion Bluetooth",
|
||||
"scanner_linuxPairingHidePin": "Masquer le code PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si nécessaire).",
|
||||
"scanner_linuxPairingShowPin": "Afficher le code PIN",
|
||||
"repeater_cliQuickClockSync": "Synchronisation de l'horloge",
|
||||
"repeater_cliQuickDiscovery": "Découvrir les voisins"
|
||||
}
|
||||
+2103
File diff suppressed because it is too large
Load Diff
+174
-2
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
||||
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
|
||||
"map_setAsMyLocation": "Imposta come la mia posizione"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Sovrapposizioni della chiave ripetitore",
|
||||
"map_runTraceWithReturnPath": "Tornare indietro sullo stesso percorso",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Quando si apre una chat con messaggi non letti, scorrete verso l'alto fino al primo messaggio non letto, invece che al più recente.",
|
||||
"chat_sendCooldown": "Si prega di attendere un momento prima di inviare nuovamente.",
|
||||
"appSettings_jumpToOldestUnread": "Vai al messaggio più vecchio non letto",
|
||||
"appSettings_languageHu": "Ungherese",
|
||||
"appSettings_languageJa": "Giapponese",
|
||||
"appSettings_languageKo": "Coreano",
|
||||
"radioStats_tooltip": "Statistiche per radio e reti",
|
||||
"radioStats_screenTitle": "Statistiche radio",
|
||||
"radioStats_notConnected": "Connettiti a un dispositivo per visualizzare le statistiche radio.",
|
||||
"radioStats_firmwareTooOld": "Le statistiche radio richiedono il firmware versione 8 o successiva.",
|
||||
"radioStats_noiseFloor": "Livello di rumore: {noiseDbm} dBm",
|
||||
"radioStats_waiting": "In attesa dei dati…",
|
||||
"radioStats_lastRssi": "Ultimo valore RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Ultimo SNR: {snr} dB",
|
||||
"radioStats_txAir": "Tempo di trasmissione in diretta (totale): {seconds} s",
|
||||
"radioStats_rxAir": "Tempo di trasmissione RX (totale): {seconds} s",
|
||||
"radioStats_chartCaption": "Livello di rumore (dBm) misurato su campioni recenti.",
|
||||
"radioStats_stripNoise": "Livello di rumore: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Recupero delle statistiche radio…",
|
||||
"radioStats_settingsTile": "Statistiche radio",
|
||||
"radioStats_settingsSubtitle": "Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerTitle": "Tradurre prima di inviare",
|
||||
"translation_enableSubtitle": "Tradurre i messaggi in arrivo e consentire la traduzione preventiva prima dell'invio.",
|
||||
"translation_enableTitle": "Abilitare la traduzione",
|
||||
"translation_title": "Traduzione",
|
||||
"translation_composerSubtitle": "Controlla lo stato predefinito dell'icona di traduzione del compositore.",
|
||||
"translation_targetLanguage": "Lingua di destinazione",
|
||||
"translation_useAppLanguage": "Utilizza la lingua dell'app",
|
||||
"translation_downloadedModelLabel": "Modello scaricato",
|
||||
"translation_presetModelLabel": "Modello predefinito di Hugging Face",
|
||||
"translation_manualUrlLabel": "URL del modello manuale",
|
||||
"translation_downloadModel": "Scarica il modello",
|
||||
"translation_downloading": "Inizio download...",
|
||||
"translation_working": "Lavoro...",
|
||||
"translation_stop": "Smetta",
|
||||
"translation_downloadedModels": "Modelli scaricati",
|
||||
"translation_mergingChunks": "Unione dei frammenti scaricati in un unico file...",
|
||||
"translation_deleteModel": "Elimina modello",
|
||||
"translation_modelDownloaded": "Modello di traduzione scaricato.",
|
||||
"translation_downloadStopped": "Il download è stato interrotto.",
|
||||
"translation_downloadFailed": "Download fallito: {error}",
|
||||
"translation_enterUrlFirst": "Inserite innanzitutto l'URL del modello.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_messageTranslation": "Traduzione del messaggio",
|
||||
"translation_translateBeforeSending": "Tradurre prima di inviare",
|
||||
"translation_composerDisabledHint": "Invia messaggi utilizzando la lingua originale, scritta.",
|
||||
"translation_composerEnabledHint": "I messaggi verranno tradotti prima di essere inviati.",
|
||||
"translation_translateTo": "Tradurre in {language}",
|
||||
"translation_translationOptions": "Opzioni di traduzione",
|
||||
"translation_systemLanguage": "Lingua del sistema",
|
||||
"scanner_linuxPairingPinPrompt": "Inserire il codice PIN per {deviceName} (lasciare vuoto se non presente).",
|
||||
"scanner_linuxPairingShowPin": "Mostra PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN per l'accoppiamento Bluetooth",
|
||||
"scanner_linuxPairingHidePin": "Nascondi il PIN",
|
||||
"repeater_cliQuickClockSync": "Sincronizzazione dell'orologio",
|
||||
"repeater_cliQuickDiscovery": "Scopri i Vicini"
|
||||
}
|
||||
+2103
File diff suppressed because it is too large
Load Diff
+2103
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,10 @@ import 'app_localizations_de.dart';
|
||||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_es.dart';
|
||||
import 'app_localizations_fr.dart';
|
||||
import 'app_localizations_hu.dart';
|
||||
import 'app_localizations_it.dart';
|
||||
import 'app_localizations_ja.dart';
|
||||
import 'app_localizations_ko.dart';
|
||||
import 'app_localizations_nl.dart';
|
||||
import 'app_localizations_pl.dart';
|
||||
import 'app_localizations_pt.dart';
|
||||
@@ -112,7 +115,10 @@ abstract class AppLocalizations {
|
||||
Locale('en'),
|
||||
Locale('es'),
|
||||
Locale('fr'),
|
||||
Locale('hu'),
|
||||
Locale('it'),
|
||||
Locale('ja'),
|
||||
Locale('ko'),
|
||||
Locale('nl'),
|
||||
Locale('pl'),
|
||||
Locale('pt'),
|
||||
@@ -826,6 +832,84 @@ abstract class AppLocalizations {
|
||||
/// **'Privacy mode disabled'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1360,6 +1444,72 @@ abstract class AppLocalizations {
|
||||
/// **'Auto route rotation disabled'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1780,6 +1930,72 @@ abstract class AppLocalizations {
|
||||
/// **'~ {days} 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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2080,6 +2296,18 @@ abstract class AppLocalizations {
|
||||
/// **'No messages yet'**
|
||||
String get chat_noMessages;
|
||||
|
||||
/// No description provided for @chat_sendMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send message'**
|
||||
String get chat_sendMessage;
|
||||
|
||||
/// No description provided for @chat_sendMessageTo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send a message to {contactName}'**
|
||||
String chat_sendMessageTo(String contactName);
|
||||
|
||||
/// No description provided for @chat_sendMessageToStart.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2110,12 +2338,6 @@ abstract class AppLocalizations {
|
||||
/// **'Location'**
|
||||
String get chat_location;
|
||||
|
||||
/// No description provided for @chat_sendMessageTo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send a message to {contactName}'**
|
||||
String chat_sendMessageTo(String contactName);
|
||||
|
||||
/// No description provided for @chat_typeMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2842,6 +3064,12 @@ abstract class AppLocalizations {
|
||||
/// **'Other Nodes'**
|
||||
String get map_otherNodes;
|
||||
|
||||
/// No description provided for @map_showOverlaps.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Repeater Key Overlaps'**
|
||||
String get map_showOverlaps;
|
||||
|
||||
/// No description provided for @map_keyPrefix.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2923,9 +3151,15 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @map_runTrace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Run Path Trace'**
|
||||
/// **'Run path trace'**
|
||||
String get map_runTrace;
|
||||
|
||||
/// No description provided for @map_runTraceWithReturnPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Return back on the same path.'**
|
||||
String get map_runTraceWithReturnPath;
|
||||
|
||||
/// No description provided for @map_removeLast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4094,6 +4328,18 @@ abstract class AppLocalizations {
|
||||
/// **'Clock'**
|
||||
String get repeater_cliQuickClock;
|
||||
|
||||
/// No description provided for @repeater_cliQuickClockSync.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clock Sync'**
|
||||
String get repeater_cliQuickClockSync;
|
||||
|
||||
/// No description provided for @repeater_cliQuickDiscovery.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discover Neighbors'**
|
||||
String get repeater_cliQuickDiscovery;
|
||||
|
||||
/// No description provided for @repeater_cliHelpAdvert.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5794,6 +6040,324 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Are you sure you want to delete all discovered contacts?'**
|
||||
String get discoveredContacts_deleteContactAllContent;
|
||||
|
||||
/// No description provided for @chat_sendCooldown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please wait a moment before sending again.'**
|
||||
String get chat_sendCooldown;
|
||||
|
||||
/// No description provided for @appSettings_jumpToOldestUnread.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Jump to oldest unread'**
|
||||
String get appSettings_jumpToOldestUnread;
|
||||
|
||||
/// No description provided for @appSettings_jumpToOldestUnreadSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'When opening a chat with unread messages, scroll to the first unread instead of the latest.'**
|
||||
String get appSettings_jumpToOldestUnreadSubtitle;
|
||||
|
||||
/// No description provided for @appSettings_languageHu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hungarian'**
|
||||
String get appSettings_languageHu;
|
||||
|
||||
/// No description provided for @appSettings_languageJa.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Japanese'**
|
||||
String get appSettings_languageJa;
|
||||
|
||||
/// No description provided for @appSettings_languageKo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Korean'**
|
||||
String get appSettings_languageKo;
|
||||
|
||||
/// No description provided for @radioStats_tooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radio & mesh stats'**
|
||||
String get radioStats_tooltip;
|
||||
|
||||
/// No description provided for @radioStats_screenTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radio stats'**
|
||||
String get radioStats_screenTitle;
|
||||
|
||||
/// No description provided for @radioStats_notConnected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connect to a device to view radio statistics.'**
|
||||
String get radioStats_notConnected;
|
||||
|
||||
/// No description provided for @radioStats_firmwareTooOld.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radio statistics require companion firmware v8 or newer.'**
|
||||
String get radioStats_firmwareTooOld;
|
||||
|
||||
/// No description provided for @radioStats_waiting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Waiting for data…'**
|
||||
String get radioStats_waiting;
|
||||
|
||||
/// No description provided for @radioStats_noiseFloor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noise floor: {noiseDbm} dBm'**
|
||||
String radioStats_noiseFloor(int noiseDbm);
|
||||
|
||||
/// No description provided for @radioStats_lastRssi.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last RSSI: {rssiDbm} dBm'**
|
||||
String radioStats_lastRssi(int rssiDbm);
|
||||
|
||||
/// No description provided for @radioStats_lastSnr.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last SNR: {snr} dB'**
|
||||
String radioStats_lastSnr(String snr);
|
||||
|
||||
/// No description provided for @radioStats_txAir.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TX airtime (total): {seconds} s'**
|
||||
String radioStats_txAir(int seconds);
|
||||
|
||||
/// No description provided for @radioStats_rxAir.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'RX airtime (total): {seconds} s'**
|
||||
String radioStats_rxAir(int seconds);
|
||||
|
||||
/// No description provided for @radioStats_chartCaption.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noise floor (dBm) over recent samples.'**
|
||||
String get radioStats_chartCaption;
|
||||
|
||||
/// No description provided for @radioStats_stripNoise.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noise floor: {noiseDbm} dBm'**
|
||||
String radioStats_stripNoise(int noiseDbm);
|
||||
|
||||
/// No description provided for @radioStats_stripWaiting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetching radio stats…'**
|
||||
String get radioStats_stripWaiting;
|
||||
|
||||
/// No description provided for @radioStats_settingsTile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radio stats'**
|
||||
String get radioStats_settingsTile;
|
||||
|
||||
/// No description provided for @radioStats_settingsSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noise floor, RSSI, SNR, and airtime'**
|
||||
String get radioStats_settingsSubtitle;
|
||||
|
||||
/// No description provided for @translation_title.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translation'**
|
||||
String get translation_title;
|
||||
|
||||
/// No description provided for @translation_enableTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable translation'**
|
||||
String get translation_enableTitle;
|
||||
|
||||
/// No description provided for @translation_enableSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translate incoming messages and allow pre-send translation.'**
|
||||
String get translation_enableSubtitle;
|
||||
|
||||
/// No description provided for @translation_composerTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translate before sending'**
|
||||
String get translation_composerTitle;
|
||||
|
||||
/// No description provided for @translation_composerSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Controls the default state of the composer translation icon.'**
|
||||
String get translation_composerSubtitle;
|
||||
|
||||
/// No description provided for @translation_targetLanguage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Target language'**
|
||||
String get translation_targetLanguage;
|
||||
|
||||
/// No description provided for @translation_useAppLanguage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Use app language'**
|
||||
String get translation_useAppLanguage;
|
||||
|
||||
/// No description provided for @translation_downloadedModelLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloaded model'**
|
||||
String get translation_downloadedModelLabel;
|
||||
|
||||
/// No description provided for @translation_presetModelLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Preset Hugging Face model'**
|
||||
String get translation_presetModelLabel;
|
||||
|
||||
/// No description provided for @translation_manualUrlLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manual model URL'**
|
||||
String get translation_manualUrlLabel;
|
||||
|
||||
/// No description provided for @translation_downloadModel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download model'**
|
||||
String get translation_downloadModel;
|
||||
|
||||
/// No description provided for @translation_downloading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading...'**
|
||||
String get translation_downloading;
|
||||
|
||||
/// No description provided for @translation_working.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Working...'**
|
||||
String get translation_working;
|
||||
|
||||
/// No description provided for @translation_stop.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stop'**
|
||||
String get translation_stop;
|
||||
|
||||
/// No description provided for @translation_mergingChunks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Merging downloaded chunks into final file...'**
|
||||
String get translation_mergingChunks;
|
||||
|
||||
/// No description provided for @translation_downloadedModels.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloaded models'**
|
||||
String get translation_downloadedModels;
|
||||
|
||||
/// No description provided for @translation_deleteModel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete model'**
|
||||
String get translation_deleteModel;
|
||||
|
||||
/// No description provided for @translation_modelDownloaded.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translation model downloaded.'**
|
||||
String get translation_modelDownloaded;
|
||||
|
||||
/// No description provided for @translation_downloadStopped.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download stopped.'**
|
||||
String get translation_downloadStopped;
|
||||
|
||||
/// No description provided for @translation_downloadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download failed: {error}'**
|
||||
String translation_downloadFailed(String error);
|
||||
|
||||
/// No description provided for @translation_enterUrlFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter a model URL first.'**
|
||||
String get translation_enterUrlFirst;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingShowPin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show PIN'**
|
||||
String get scanner_linuxPairingShowPin;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingHidePin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide PIN'**
|
||||
String get scanner_linuxPairingHidePin;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingPinTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bluetooth Pairing PIN'**
|
||||
String get scanner_linuxPairingPinTitle;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingPinPrompt.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter PIN for {deviceName} (leave blank if none).'**
|
||||
String scanner_linuxPairingPinPrompt(String deviceName);
|
||||
|
||||
/// No description provided for @translation_messageTranslation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Message translation'**
|
||||
String get translation_messageTranslation;
|
||||
|
||||
/// No description provided for @translation_translateBeforeSending.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translate before sending'**
|
||||
String get translation_translateBeforeSending;
|
||||
|
||||
/// No description provided for @translation_composerEnabledHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Messages will be translated before send.'**
|
||||
String get translation_composerEnabledHint;
|
||||
|
||||
/// No description provided for @translation_composerDisabledHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send messages in the original typed language.'**
|
||||
String get translation_composerDisabledHint;
|
||||
|
||||
/// No description provided for @translation_translateTo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translate to {language}'**
|
||||
String translation_translateTo(String language);
|
||||
|
||||
/// No description provided for @translation_translationOptions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translation options'**
|
||||
String get translation_translationOptions;
|
||||
|
||||
/// No description provided for @translation_systemLanguage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'System language'**
|
||||
String get translation_systemLanguage;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
@@ -5812,7 +6376,10 @@ class _AppLocalizationsDelegate
|
||||
'en',
|
||||
'es',
|
||||
'fr',
|
||||
'hu',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt',
|
||||
@@ -5841,8 +6408,14 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
return AppLocalizationsEs();
|
||||
case 'fr':
|
||||
return AppLocalizationsFr();
|
||||
case 'hu':
|
||||
return AppLocalizationsHu();
|
||||
case 'it':
|
||||
return AppLocalizationsIt();
|
||||
case 'ja':
|
||||
return AppLocalizationsJa();
|
||||
case 'ko':
|
||||
return AppLocalizationsKo();
|
||||
case 'nl':
|
||||
return AppLocalizationsNl();
|
||||
case 'pl':
|
||||
|
||||
@@ -398,6 +398,52 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
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
|
||||
String get settings_actions => 'Действия';
|
||||
|
||||
@@ -695,6 +741,51 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
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
|
||||
String get appSettings_battery => 'Батерия';
|
||||
|
||||
@@ -944,6 +1035,42 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Канали';
|
||||
|
||||
@@ -1112,6 +1239,14 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Няма съобщения.';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Изпрати съобщение на $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Изпрати съобщение, за да започнеш.';
|
||||
|
||||
@@ -1131,11 +1266,6 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Местоположение';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Изпрати съобщение на $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Въведете съобщение...';
|
||||
|
||||
@@ -1562,6 +1692,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Други възли';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Покриване на ключа на повтаряча';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Префикс на ключа';
|
||||
|
||||
@@ -1606,6 +1739,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Изпълни Път на Следване';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Върни се по същия път.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Премахни Последно';
|
||||
|
||||
@@ -2296,6 +2432,12 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Часовник';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Синхронизация на часовника';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Открий Съседи';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Изпраща рекламен пакет';
|
||||
|
||||
@@ -3351,4 +3493,196 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Сигурни ли сте, че искате да изтриете всички открити контакти?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Моля, изчакайте малко, преди да изпратите отново.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Преминете към най-старата непочетена статия';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Когато отворите чат с непрочетени съобщения, плъзнете надолу, за да видите първото непрочетено съобщение, вместо най-новото.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Унгарски';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Японски';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Корейски';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Статистика за радио и мрежа';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle =>
|
||||
'Статистически данни за радиопредаванията';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Свържете се с устройство, за да видите статистически данни за радиопредаване.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Статистиката на радиостанцията изисква съвместимо софтуерно решение версия 8 или по-нова.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Изчакване на данни…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Ниво на шума: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Последен RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Последна стойност на SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Време на въздух (общо): $seconds секунди';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Общо време на използване на RX (в секунди): $seconds с';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Ниво на шума (dBm) за последните измервания.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Ниво на шума: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Извличане на данни за радиото…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Статистически данни за радиостанции';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Ниво на шума, RSSI, SNR и време на пренос';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Превод';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Активирайте превода';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Превеждайте входящите съобщения и позволявайте предварително превеждане преди изпращане.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Преведете преди да изпратите';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Контролира началния статус на иконата за превод, създадена от композитора.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Целеви език';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Използвайте езика на приложението';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Изтегнат модел';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Предварително конфигуриран модел от Hugging Face';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'URL на ръководството';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Изтеглете модела';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Изтегляне...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Работа...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Спрете';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Съединяване на изтеглените части в един файл...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Изтеглени модели';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Изтриване на модела';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Моделът за превод е изтеглен.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Изтеглянето беше прекъснато.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Не успях да изтегля: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst => 'Въведете първо URL адрес на модела.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Покажи PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Скриване на PIN кода';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN за съвпадение чрез Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Въведете PIN кода за $deviceName (оставете празно, ако няма такъв).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Превод на съобщението';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending =>
|
||||
'Преведете преди да изпратите';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Съобщенията ще бъдат преведени, преди да бъдат изпратени.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Изпращайте съобщения на оригиналния въведен език.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Превеждане на $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Опции за превод';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Език на системата';
|
||||
}
|
||||
|
||||
@@ -398,6 +398,50 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Aktionen';
|
||||
|
||||
@@ -695,6 +739,49 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Akku';
|
||||
|
||||
@@ -944,6 +1031,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Kanäle';
|
||||
|
||||
@@ -1116,6 +1238,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Noch keine Nachrichten.';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Sende eine Nachricht an $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Eine Nachricht senden, um anzufangen.';
|
||||
|
||||
@@ -1135,11 +1265,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Ort';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Sende eine Nachricht an $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Eine Nachricht eingeben...';
|
||||
|
||||
@@ -1564,6 +1689,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Andere Knoten';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Überlappungen der Repeater-Taste';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Schlüsselpräfix';
|
||||
|
||||
@@ -1608,6 +1736,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Pfadverlauf ausführen';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath =>
|
||||
'Auf dem gleichen Pfad zurückkehren.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Letztes Entfernen';
|
||||
|
||||
@@ -2300,6 +2432,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Uhr';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Uhr Synchronisieren';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Entdecke Nachbarn';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Sendet eine Ankündigung';
|
||||
|
||||
@@ -3365,4 +3503,197 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Sind Sie sicher, dass Sie alle gefundenen Kontakte löschen möchten?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Bitte warten Sie einen Moment, bevor Sie erneut senden.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Zum ältesten, nicht gelesenen Eintrag springen';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Wenn Sie ein Chatfenster öffnen, in dem Nachrichten vorhanden sind, die noch nicht gelesen wurden, scrollen Sie zu der ersten unlesenen Nachricht, anstatt zur neuesten.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Ungarisch';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japanisch';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Koreanisch';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Daten zu Radio- und Mesh-Netzwerken';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Senderinformationen';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Verbinden Sie ein Gerät, um Radiostatisiken anzuzeigen.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Für die Verwendung der Funkstatistiken ist die Firmware-Version 8 oder höher erforderlich.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Warte auf Daten…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Rauschpegel: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Letzter RSSI-Wert: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Letzter SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Gesamt-TX-Zeit: $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Gesamt-RX-Zeit: $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Rauschpegel (dBm) basierend auf den letzten Messwerten.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Rauschpegel: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Abrufen von Radiostatus…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Senderinformationen';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Übersetzung';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Aktivieren Sie die Übersetzung';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Nachrichten empfangen und übersetzen sowie die Möglichkeit bieten, Nachrichten vor dem Versenden zu übersetzen.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Übersetzen Sie vor dem Versenden';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Zielsprache';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Verwenden Sie die App-Sprache';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Heruntergeladenes Modell';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Vordefinierter Hugging Face-Modell';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'URL für das manuelle Modell';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Modell herunterladen';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Herunterladen...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Arbeiten...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Stopp';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Zusammenführen der heruntergeladenen Teile in die finale Datei...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Heruntergeladene Modelle';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Modell löschen';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded =>
|
||||
'Übersetzungsmotor heruntergeladen.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Herunterladen abgebrochen.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Download fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst =>
|
||||
'Geben Sie zunächst die URL eines Modells ein.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'PIN anzeigen';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PIN ausblenden';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth-Paarungs-PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Geben Sie die PIN für $deviceName ein (leer lassen, falls keine).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Nachricht übersetzen';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending =>
|
||||
'Übersetzen Sie vor dem Versenden';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Die Nachrichten werden vor dem Versenden übersetzt.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Nachrichten in der ursprünglichen, getippten Sprache senden.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Übersetzen Sie auf $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Übersetzungsmöglichkeiten';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Sprache des Systems';
|
||||
}
|
||||
|
||||
@@ -392,6 +392,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Actions';
|
||||
|
||||
@@ -684,6 +726,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Battery';
|
||||
|
||||
@@ -930,6 +1014,40 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Channels';
|
||||
|
||||
@@ -1095,6 +1213,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'No messages yet';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Send a message to $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Send a message to get started';
|
||||
|
||||
@@ -1114,11 +1240,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Location';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Send a message to $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Type a message...';
|
||||
|
||||
@@ -1538,6 +1659,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Other Nodes';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Repeater Key Overlaps';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Key Prefix';
|
||||
|
||||
@@ -1578,7 +1702,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get map_tapToAdd => 'Tap on nodes to add them to the path.';
|
||||
|
||||
@override
|
||||
String get map_runTrace => 'Run Path Trace';
|
||||
String get map_runTrace => 'Run path trace';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Return back on the same path.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Remove Last';
|
||||
@@ -2255,6 +2382,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Clock';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Clock Sync';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Discover Neighbors';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Sends an advertisement packet';
|
||||
|
||||
@@ -3297,4 +3430,191 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Are you sure you want to delete all discovered contacts?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown => 'Please wait a moment before sending again.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread => 'Jump to oldest unread';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'When opening a chat with unread messages, scroll to the first unread instead of the latest.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Hungarian';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japanese';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Korean';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Radio & mesh stats';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Radio stats';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Connect to a device to view radio statistics.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Radio statistics require companion firmware v8 or newer.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Waiting for data…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Noise floor: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Last RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Last SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'TX airtime (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'RX airtime (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Noise floor (dBm) over recent samples.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Noise floor: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Fetching radio stats…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Radio stats';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Noise floor, RSSI, SNR, and airtime';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Translation';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Enable translation';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Translate incoming messages and allow pre-send translation.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Translate before sending';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Controls the default state of the composer translation icon.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Target language';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Use app language';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Downloaded model';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel => 'Preset Hugging Face model';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'Manual model URL';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Download model';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Downloading...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Working...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Stop';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Merging downloaded chunks into final file...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Downloaded models';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Delete model';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Translation model downloaded.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Download stopped.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Download failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst => 'Enter a model URL first.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Show PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Hide PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth Pairing PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Enter PIN for $deviceName (leave blank if none).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Message translation';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Translate before sending';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Messages will be translated before send.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Send messages in the original typed language.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Translate to $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Translation options';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'System language';
|
||||
}
|
||||
|
||||
@@ -396,6 +396,51 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Acciones';
|
||||
|
||||
@@ -694,6 +739,49 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Batería';
|
||||
|
||||
@@ -944,6 +1032,42 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Canales';
|
||||
|
||||
@@ -1114,6 +1238,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Aún no hay mensajes';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Enviar un mensaje a $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Enviar un mensaje para comenzar';
|
||||
|
||||
@@ -1133,11 +1265,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Ubicación';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Enviar un mensaje a $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Escribe un mensaje...';
|
||||
|
||||
@@ -1561,6 +1688,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Otros Nodos';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Superposiciones de tecla repetidora';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Prefijo de clave';
|
||||
|
||||
@@ -1604,6 +1734,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Ejecutar Rastreo de Ruta';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Volver atrás por el mismo camino.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Eliminar último';
|
||||
|
||||
@@ -2293,6 +2426,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Reloj';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Sincronización del reloj';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Descubrir Vecinos';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Envía un paquete de publicidad';
|
||||
|
||||
@@ -3357,4 +3496,197 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'¿Está seguro de que desea eliminar todos los contactos descubiertos!';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Por favor, espere un momento antes de reenviar.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Salta a los mensajes más antiguos sin leer';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Cuando abras una conversación con mensajes sin leer, desplázate hacia el primer mensaje sin leer en lugar del más reciente.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Húngaro';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonés';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Coreano';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Estadísticas de radio y malla';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Estadísticas de radio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Conéctese a un dispositivo para visualizar estadísticas de radio.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Las estadísticas de radio requieren un firmware compatible v8 o posterior.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Esperando datos…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Nivel de ruido: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Último RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Último SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Tiempo de emisión en Texas (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Tiempo de transmisión de RX (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Nivel de ruido (dBm) en muestras recientes.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Nivel de ruido: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Obteniendo estadísticas de la radio…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Estadísticas de radio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Nivel de ruido, RSSI, SNR y tiempo de transmisión';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Traducción';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Habilitar la traducción';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Traducir los mensajes entrantes y permitir la traducción previa al envío.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Traducir antes de enviar';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Controla el estado predeterminado del icono de traducción del compositor.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Idioma de destino';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage =>
|
||||
'Utilizar el idioma de la aplicación';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Modelo descargado';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Modelo predefinido de Hugging Face';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'URL del modelo manual';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Descargar el modelo';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Descargando...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Trabajando...';
|
||||
|
||||
@override
|
||||
String get translation_stop => '¡Detente!';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Combinando los fragmentos descargados en el archivo final...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Modelos descargados';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Eliminar modelo';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Modelo de traducción descargado.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'La descarga se ha detenido.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'No se pudo descargar: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst =>
|
||||
'Primero, introduzca la URL del modelo.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostrar código PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Ocultar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle =>
|
||||
'PIN para emparejar dispositivos Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Introduzca el código PIN para $deviceName (deje en blanco si no hay ninguno).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Traducción del mensaje';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Traducir antes de enviar';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Los mensajes serán traducidos antes de ser enviados.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Envía mensajes utilizando el lenguaje escrito original.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Traducir a $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Opciones de traducción';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Idioma del sistema';
|
||||
}
|
||||
|
||||
@@ -400,6 +400,52 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get settings_privacyModeDisabled =>
|
||||
'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
|
||||
String get settings_actions => 'Actions';
|
||||
|
||||
@@ -513,10 +559,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get settings_bandwidth => 'Bande passante';
|
||||
|
||||
@override
|
||||
String get settings_spreadingFactor => 'Facteur de répartition';
|
||||
String get settings_spreadingFactor => 'Facteur de répartition (SF)';
|
||||
|
||||
@override
|
||||
String get settings_codingRate => 'Taux de codage';
|
||||
String get settings_codingRate => 'Taux de codage (CR)';
|
||||
|
||||
@override
|
||||
String get settings_txPower => 'TX Puissance (dBm)';
|
||||
@@ -698,6 +744,50 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Batterie';
|
||||
|
||||
@@ -856,7 +946,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String contacts_searchRoomServers(int number, String str) {
|
||||
return 'Rechercher $number$str serveurs de salle...';
|
||||
return 'Rechercher $number$str room server...';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -947,6 +1037,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Canaux';
|
||||
|
||||
@@ -1103,7 +1229,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
'N\'importe qui peut rejoindre les canaux #hashtag.';
|
||||
|
||||
@override
|
||||
String get channels_scanQrCode => 'Scanner un code QR';
|
||||
String get channels_scanQrCode => 'Scanner un QR code';
|
||||
|
||||
@override
|
||||
String get channels_scanQrCodeComingSoon => 'Bientôt disponible';
|
||||
@@ -1117,6 +1243,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Aucun message pour le moment.';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Envoyer un message à $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Envoyer un message pour commencer';
|
||||
|
||||
@@ -1136,11 +1270,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Emplacement';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Envoyer un message à $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Saisir un message...';
|
||||
|
||||
@@ -1363,7 +1492,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get chat_floodModeSubtitle =>
|
||||
'Utiliser le commutateur de routage dans la barre d\'application';
|
||||
'Désactive l\'apprentissage du chemin (à éviter). Utiliser le commutateur de routage dans la barre d\'application pour rebasculer en mode auto par la suite.';
|
||||
|
||||
@override
|
||||
String get chat_floodModeEnabled =>
|
||||
@@ -1488,7 +1617,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get map_repeater => 'Répéteur';
|
||||
|
||||
@override
|
||||
String get map_room => 'Salle';
|
||||
String get map_room => 'Room Server';
|
||||
|
||||
@override
|
||||
String get map_sensor => 'Capteur';
|
||||
@@ -1569,6 +1698,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Autres nœuds';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Chevauchement de la touche répétitive';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Préfixe clé';
|
||||
|
||||
@@ -1601,7 +1733,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get map_sharedPin => 'Clé partagée';
|
||||
|
||||
@override
|
||||
String get map_joinRoom => 'Rejoindre la salle';
|
||||
String get map_joinRoom => 'Rejoindre le room server';
|
||||
|
||||
@override
|
||||
String get map_manageRepeater => 'Gérer le répéteur';
|
||||
@@ -1613,6 +1745,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Exécuter la traçage de chemin';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Revenir sur le même chemin.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Supprimer le dernier';
|
||||
|
||||
@@ -1867,7 +2002,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get path_noRepeatersFound =>
|
||||
'Aucun répéteur ou serveur de salle n\'a été trouvé.';
|
||||
'Aucun répéteur ou room server n\'a été trouvé.';
|
||||
|
||||
@override
|
||||
String get path_customPathsRequire =>
|
||||
@@ -2077,10 +2212,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get repeater_bandwidth => 'Bande passante';
|
||||
|
||||
@override
|
||||
String get repeater_spreadingFactor => 'Facteur de répartition';
|
||||
String get repeater_spreadingFactor => 'Facteur de répartition (SF)';
|
||||
|
||||
@override
|
||||
String get repeater_codingRate => 'Taux de codage';
|
||||
String get repeater_codingRate => 'Taux de codage (CR)';
|
||||
|
||||
@override
|
||||
String get repeater_locationSettings => 'Paramètres de localisation';
|
||||
@@ -2103,7 +2238,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get repeater_features => 'Fonctionnalités';
|
||||
|
||||
@override
|
||||
String get repeater_packetForwarding => 'Transfert de paquets';
|
||||
String get repeater_packetForwarding => 'Mode répéteur';
|
||||
|
||||
@override
|
||||
String get repeater_packetForwardingSubtitle =>
|
||||
@@ -2310,6 +2445,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Horloge';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Synchronisation de l\'horloge';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Découvrir les voisins';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Envoie un paquet d\'annonce';
|
||||
|
||||
@@ -2764,11 +2905,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get community_scanQr => 'Scanner la communauté QR';
|
||||
String get community_scanQr => 'Scanner un QR code de communauté';
|
||||
|
||||
@override
|
||||
String get community_scanInstructions =>
|
||||
'Pointez l\'appareil photo vers un code QR communautaire.';
|
||||
'Pointez l\'appareil photo vers un QR code de communauté.';
|
||||
|
||||
@override
|
||||
String get community_showQr => 'Afficher le QR Code';
|
||||
@@ -2808,7 +2949,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
'Les canaux hashtag de la communauté ne sont accessibles qu\'aux membres de la communauté';
|
||||
|
||||
@override
|
||||
String get community_invalidQrCode => 'Code QR de communauté non valide';
|
||||
String get community_invalidQrCode => 'QR code de communauté non valide';
|
||||
|
||||
@override
|
||||
String get community_alreadyMember => 'Déjà membre';
|
||||
@@ -2832,7 +2973,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get community_scanOrCreate =>
|
||||
'Scanner un code QR ou créer une communauté pour commencer';
|
||||
'Scanner un QR code ou créer une communauté pour commencer';
|
||||
|
||||
@override
|
||||
String get community_manageCommunities => 'Gérer les Communautés';
|
||||
@@ -2860,7 +3001,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String community_regenerateSecretConfirm(String name) {
|
||||
return 'Régénérer la clé secrète pour \"$name\" ? Tous les membres devront scanner le nouveau code QR pour continuer à communiquer.';
|
||||
return 'Régénérer la clé secrète pour \"$name\" ? Tous les membres devront scanner le nouveau QR code pour continuer à communiquer.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2881,7 +3022,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String community_scanToUpdateSecret(String name) {
|
||||
return 'Scanner le nouveau code QR pour mettre à jour le mot de passe pour \"$name\"';
|
||||
return 'Scanner le nouveau QR code pour mettre à jour le mot de passe pour \"$name\"';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3129,11 +3270,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get contacts_repeaterPing => 'Pinguer le répéteur';
|
||||
|
||||
@override
|
||||
String get contacts_roomPathTrace =>
|
||||
'Traçage du chemin vers le serveur de la salle';
|
||||
String get contacts_roomPathTrace => 'Traçage du chemin vers le room server';
|
||||
|
||||
@override
|
||||
String get contacts_roomPing => 'Pinguer le serveur de la salle';
|
||||
String get contacts_roomPing => 'Pinguer le room server';
|
||||
|
||||
@override
|
||||
String get contacts_chatTraceRoute => 'Tracer le chemin';
|
||||
@@ -3239,7 +3379,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get settings_gpxExportRepeaters =>
|
||||
'Exporter les répéteurs / serveur de salle au format GPX';
|
||||
'Exporter les répéteurs / room servers au format GPX';
|
||||
|
||||
@override
|
||||
String get settings_gpxExportRepeatersSubtitle =>
|
||||
@@ -3277,7 +3417,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get settings_gpxExportRepeatersRoom =>
|
||||
'Emplacements des serveurs de répéteur et de salle';
|
||||
'Emplacements des répéteurs et room servers';
|
||||
|
||||
@override
|
||||
String get settings_gpxExportChat => 'Emplacements des compagnons';
|
||||
@@ -3328,11 +3468,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get contactsSettings_autoAddRoomServersTitle =>
|
||||
'Ajouter automatiquement les serveurs de salle';
|
||||
'Ajouter automatiquement les room servers';
|
||||
|
||||
@override
|
||||
String get contactsSettings_autoAddRoomServersSubtitle =>
|
||||
'Autoriser le compagnon à ajouter automatiquement les serveurs de salles découverts';
|
||||
'Autoriser le compagnon à ajouter automatiquement les room servers découverts';
|
||||
|
||||
@override
|
||||
String get contactsSettings_autoAddSensorsTitle =>
|
||||
@@ -3379,4 +3519,198 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Êtes-vous sûr de vouloir supprimer tous les contacts découverts ?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Veuillez patienter un instant avant de réessayer.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Accéder au message le plus ancien non lu';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Lorsque vous ouvrez une conversation contenant des messages non lus, faites défiler la page jusqu\'au premier message non lu, plutôt que jusqu\'au dernier.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Hongrois';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonais';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Coréen';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip =>
|
||||
'Statistiques des radios et des réseaux sans fil';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Statistiques de radio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Connectez-vous à un appareil pour visualiser les statistiques de la radio.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Les statistiques radio nécessitent un firmware compatible v8 ou une version ultérieure.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'En attente des données…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Niveau de bruit : $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Dernier RSSI : $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Dernier SNR : $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Temps d\'antenne à la télévision du Texas (total) : $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Temps d\'utilisation de l\'appareil RX (total) : $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Niveau de bruit (dBm) sur les échantillons récents.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Niveau de bruit : $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting =>
|
||||
'Récupération des statistiques de la radio…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Statistiques de radio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d\'antenne';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Traduction';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Activer la traduction';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Traduire les messages entrants et permettre la traduction avant l\'envoi.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Traduire avant d\'envoyer';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Contrôle l\'état par défaut de l\'icône de traduction du composant.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Langue cible';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage =>
|
||||
'Utiliser la langue de l\'application';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Modèle téléchargé';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel => 'Modèle Hugging Face préconfiguré';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'URL du modèle manuel';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Télécharger le modèle';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Téléchargement...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Au travail...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Arrêtez';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Fusion des fragments téléchargés dans le fichier final...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Modèles téléchargés';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Supprimer le modèle';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Modèle de traduction téléchargé.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped =>
|
||||
'Le téléchargement a été interrompu.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Échec du téléchargement : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst => 'Entrez d\'abord l\'URL du modèle.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Afficher le code PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Masquer le code PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle =>
|
||||
'Code PIN pour la connexion Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Entrez le code PIN pour $deviceName (laissez vide si nécessaire).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Traduction du message';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Traduire avant d\'envoyer';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Les messages seront traduits avant d\'être envoyés.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Envoyez des messages dans la langue originale, telle que vous l\'avez tapée.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Traduire en $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Options de traduction';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Langue du système';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -398,6 +398,52 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Azioni';
|
||||
|
||||
@@ -695,6 +741,50 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Batteria';
|
||||
|
||||
@@ -943,6 +1033,42 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Canali';
|
||||
|
||||
@@ -1113,6 +1239,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Nessun messaggio ancora';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Invia un messaggio a $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Invia un messaggio per iniziare';
|
||||
|
||||
@@ -1132,11 +1266,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Posizione';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Invia un messaggio a $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Digita un messaggio...';
|
||||
|
||||
@@ -1561,6 +1690,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Altri Nodi';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Prefisso Chiave';
|
||||
|
||||
@@ -1603,6 +1735,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Esegui Path Trace';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath =>
|
||||
'Tornare indietro sullo stesso percorso';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Rimuovi ultimo';
|
||||
|
||||
@@ -2293,6 +2429,12 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Orologio';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Sincronizzazione dell\'orologio';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Scopri i Vicini';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Invia un pacchetto pubblicitario';
|
||||
|
||||
@@ -3358,4 +3500,196 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Sei sicuro di voler eliminare tutti i contatti scoperti?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Si prega di attendere un momento prima di inviare nuovamente.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Vai al messaggio più vecchio non letto';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Quando si apre una chat con messaggi non letti, scorrete verso l\'alto fino al primo messaggio non letto, invece che al più recente.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Ungherese';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Giapponese';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Coreano';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Statistiche per radio e reti';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Statistiche radio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Connettiti a un dispositivo per visualizzare le statistiche radio.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Le statistiche radio richiedono il firmware versione 8 o successiva.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'In attesa dei dati…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Livello di rumore: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Ultimo valore RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Ultimo SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Tempo di trasmissione in diretta (totale): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Tempo di trasmissione RX (totale): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Livello di rumore (dBm) misurato su campioni recenti.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Livello di rumore: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Recupero delle statistiche radio…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Statistiche radio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Traduzione';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Abilitare la traduzione';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Tradurre i messaggi in arrivo e consentire la traduzione preventiva prima dell\'invio.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Tradurre prima di inviare';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Controlla lo stato predefinito dell\'icona di traduzione del compositore.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Lingua di destinazione';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Utilizza la lingua dell\'app';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Modello scaricato';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Modello predefinito di Hugging Face';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'URL del modello manuale';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Scarica il modello';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Inizio download...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Lavoro...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Smetta';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Unione dei frammenti scaricati in un unico file...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Modelli scaricati';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Elimina modello';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Modello di traduzione scaricato.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Il download è stato interrotto.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Download fallito: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst =>
|
||||
'Inserite innanzitutto l\'URL del modello.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostra PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Nascondi il PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle =>
|
||||
'PIN per l\'accoppiamento Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Inserire il codice PIN per $deviceName (lasciare vuoto se non presente).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Traduzione del messaggio';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Tradurre prima di inviare';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'I messaggi verranno tradotti prima di essere inviati.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Invia messaggi utilizzando la lingua originale, scritta.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Tradurre in $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Opzioni di traduzione';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Lingua del sistema';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -313,7 +313,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settings_nodeSettings => 'Node Instellingen';
|
||||
|
||||
@override
|
||||
String get settings_nodeName => 'Node Naam';
|
||||
String get settings_nodeName => 'Nodenaam';
|
||||
|
||||
@override
|
||||
String get settings_nodeNameNotSet => 'Niet ingesteld';
|
||||
@@ -395,6 +395,50 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Acties';
|
||||
|
||||
@@ -408,7 +452,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settings_advertisementSent => 'Advertentie verzonden';
|
||||
|
||||
@override
|
||||
String get settings_syncTime => 'Synchronisatie Tijd';
|
||||
String get settings_syncTime => 'Tijd Synchroniseren';
|
||||
|
||||
@override
|
||||
String get settings_syncTimeSubtitle =>
|
||||
@@ -428,7 +472,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settings_rebootDevice => 'Apparaat opnieuw opstarten';
|
||||
|
||||
@override
|
||||
String get settings_rebootDeviceSubtitle => 'Herstart het MeshCore apparaat';
|
||||
String get settings_rebootDeviceSubtitle => 'Herstart het MeshCore-apparaat';
|
||||
|
||||
@override
|
||||
String get settings_rebootDeviceConfirm =>
|
||||
@@ -512,7 +556,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settings_codingRate => 'Codeertarief';
|
||||
|
||||
@override
|
||||
String get settings_txPower => 'TX Vermogen (dBm)';
|
||||
String get settings_txPower => 'TX-Vermogen (dBm)';
|
||||
|
||||
@override
|
||||
String get settings_txPowerHelper => '0 - 22';
|
||||
@@ -521,11 +565,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settings_txPowerInvalid => 'Ongeldige TX-vermogen (0-22 dBm)';
|
||||
|
||||
@override
|
||||
String get settings_clientRepeat => 'Herhalen: Afgekoppeld';
|
||||
String get settings_clientRepeat => 'Off-Grid Herhalen';
|
||||
|
||||
@override
|
||||
String get settings_clientRepeatSubtitle =>
|
||||
'Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.';
|
||||
'Laat dit apparaat de berichten van andere apparaten doorsturen.';
|
||||
|
||||
@override
|
||||
String get settings_clientRepeatFreqWarning =>
|
||||
@@ -689,6 +733,49 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Batterij';
|
||||
|
||||
@@ -759,19 +846,19 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get appSettings_allTime => 'Altijd';
|
||||
|
||||
@override
|
||||
String get appSettings_lastHour => 'Laat uur';
|
||||
String get appSettings_lastHour => 'Afgelopen uur';
|
||||
|
||||
@override
|
||||
String get appSettings_last6Hours => 'laatste 6 uur';
|
||||
String get appSettings_last6Hours => 'Afgelopen 6 uur';
|
||||
|
||||
@override
|
||||
String get appSettings_last24Hours => 'De laatste 24 uur';
|
||||
String get appSettings_last24Hours => 'Afgelopen 24 uur';
|
||||
|
||||
@override
|
||||
String get appSettings_lastWeek => 'Laatste week';
|
||||
String get appSettings_lastWeek => 'Afgelopen week';
|
||||
|
||||
@override
|
||||
String get appSettings_offlineMapCache => 'Offline Kaarten Cache';
|
||||
String get appSettings_offlineMapCache => 'Offline Kaartcache';
|
||||
|
||||
@override
|
||||
String get appSettings_unitsTitle => 'Eenheden';
|
||||
@@ -937,6 +1024,40 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Kanaal';
|
||||
|
||||
@@ -1064,32 +1185,32 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get channels_sortUnread => 'Ongelezen';
|
||||
|
||||
@override
|
||||
String get channels_createPrivateChannel => 'Maak een Privé Kanaal';
|
||||
String get channels_createPrivateChannel => 'PrivéKanaal Aanmaken';
|
||||
|
||||
@override
|
||||
String get channels_createPrivateChannelDesc =>
|
||||
'Beveiligd met een geheime sleutel.';
|
||||
|
||||
@override
|
||||
String get channels_joinPrivateChannel => 'Sluit een Privé Kanaal aan';
|
||||
String get channels_joinPrivateChannel => 'PrivéKanaal Toetreden';
|
||||
|
||||
@override
|
||||
String get channels_joinPrivateChannelDesc =>
|
||||
'Handmatig een geheime sleutel invoeren.';
|
||||
'Voer handmatig een geheime sleutel in.';
|
||||
|
||||
@override
|
||||
String get channels_joinPublicChannel => 'Sluit het Open Kanaal';
|
||||
String get channels_joinPublicChannel => 'Publiek Kanaal Toetreden';
|
||||
|
||||
@override
|
||||
String get channels_joinPublicChannelDesc =>
|
||||
'Iedereen kan dit kanaal aanmelden.';
|
||||
'Iedereen kan toetreden tot dit kanaal.';
|
||||
|
||||
@override
|
||||
String get channels_joinHashtagChannel => 'Sluit een Hashtag Kanaal';
|
||||
String get channels_joinHashtagChannel => 'Hashtag-kanaal Aanmaken';
|
||||
|
||||
@override
|
||||
String get channels_joinHashtagChannelDesc =>
|
||||
'Iedereen kan lid worden van hashtag-kanalen.';
|
||||
'Iedereen kan toetreden tot hashtag-kanalen.';
|
||||
|
||||
@override
|
||||
String get channels_scanQrCode => 'Scan een QR-code';
|
||||
@@ -1106,6 +1227,14 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Nog geen berichten.';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Verstuur een bericht naar $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Een bericht sturen om te beginnen';
|
||||
|
||||
@@ -1125,11 +1254,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Locatie';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Verstuur een bericht naar $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Type een bericht...';
|
||||
|
||||
@@ -1553,6 +1677,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Andere Nodes';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Herhalingssleutel overlapt';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Prefix sleutel';
|
||||
|
||||
@@ -1585,7 +1712,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get map_sharedPin => 'Gedeelde pin';
|
||||
|
||||
@override
|
||||
String get map_joinRoom => 'Sluit Kamer';
|
||||
String get map_joinRoom => 'Kamer Toetreden';
|
||||
|
||||
@override
|
||||
String get map_manageRepeater => 'Beheer Repeater';
|
||||
@@ -1597,6 +1724,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Padeshulp traceren';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Terugkeren op hetzelfde pad.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Verwijder Laatste';
|
||||
|
||||
@@ -1874,7 +2004,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get room_management => 'Beheer Server Kamer';
|
||||
|
||||
@override
|
||||
String get repeater_managementTools => 'Beheerinstrumenten';
|
||||
String get repeater_managementTools => 'Beheerfuncties';
|
||||
|
||||
@override
|
||||
String get repeater_status => 'Status';
|
||||
@@ -1900,7 +2030,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get repeater_neighbors => 'Buren';
|
||||
|
||||
@override
|
||||
String get repeater_neighborsSubtitle => 'Bekijk nul hops buren.';
|
||||
String get repeater_neighborsSubtitle => 'Bekijk nul-hopsburen.';
|
||||
|
||||
@override
|
||||
String get repeater_settings => 'Instellingen';
|
||||
@@ -1966,10 +2096,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get repeater_noiseFloor => 'Ruisvloer';
|
||||
|
||||
@override
|
||||
String get repeater_txAirtime => 'TX Airtime';
|
||||
String get repeater_txAirtime => 'TX-zendtijd';
|
||||
|
||||
@override
|
||||
String get repeater_rxAirtime => 'RX Airtime';
|
||||
String get repeater_rxAirtime => 'RX-zendtijd';
|
||||
|
||||
@override
|
||||
String get repeater_packetStatistics => 'Pakketstatistieken';
|
||||
@@ -2014,7 +2144,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get repeater_settingsTitle => 'Repeater Instellingen';
|
||||
String get repeater_settingsTitle => 'Repeaterinstellingen';
|
||||
|
||||
@override
|
||||
String get repeater_basicSettings => 'Basisinstellingen';
|
||||
@@ -2023,19 +2153,19 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get repeater_repeaterName => 'Repeaternaam';
|
||||
|
||||
@override
|
||||
String get repeater_repeaterNameHelper => 'Weergave naam voor deze repeater';
|
||||
String get repeater_repeaterNameHelper => 'Weergavenaam voor deze repeater';
|
||||
|
||||
@override
|
||||
String get repeater_adminPassword => 'Admin wachtwoord';
|
||||
|
||||
@override
|
||||
String get repeater_adminPasswordHelper => 'Volledige toegangspaswoord';
|
||||
String get repeater_adminPasswordHelper => 'Wachtwoord administratortoegang';
|
||||
|
||||
@override
|
||||
String get repeater_guestPassword => 'Wachtwoord Gast';
|
||||
String get repeater_guestPassword => 'Gast wachtwoord';
|
||||
|
||||
@override
|
||||
String get repeater_guestPasswordHelper => 'Leesbeheer wachtwoord';
|
||||
String get repeater_guestPasswordHelper => 'Wachtwoord gasttoegen';
|
||||
|
||||
@override
|
||||
String get repeater_radioSettings => 'Radio Instellingen';
|
||||
@@ -2062,7 +2192,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get repeater_codingRate => 'Codeertarief';
|
||||
|
||||
@override
|
||||
String get repeater_locationSettings => 'Locatie Instellingen';
|
||||
String get repeater_locationSettings => 'Locatie-instellingen';
|
||||
|
||||
@override
|
||||
String get repeater_latitude => 'Breedtegraad';
|
||||
@@ -2094,14 +2224,14 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
'Toegestane leesbeheer toegang voor gasten.';
|
||||
|
||||
@override
|
||||
String get repeater_privacyMode => 'Privacy Modus';
|
||||
String get repeater_privacyMode => 'Privacymodus';
|
||||
|
||||
@override
|
||||
String get repeater_privacyModeSubtitle =>
|
||||
'Naam/locatie verbergen in advertenties';
|
||||
|
||||
@override
|
||||
String get repeater_advertisementSettings => 'Advertentie Instellingen';
|
||||
String get repeater_advertisementSettings => 'Advertentie-instellingen';
|
||||
|
||||
@override
|
||||
String get repeater_localAdvertInterval => 'Lokale Advertentie Interval';
|
||||
@@ -2206,7 +2336,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get repeater_refreshGuestAccess => 'Toegang Gast Vernieuwen';
|
||||
|
||||
@override
|
||||
String get repeater_refreshPrivacyMode => 'Privacy Mode vernieuwen';
|
||||
String get repeater_refreshPrivacyMode => 'Privacymode vernieuwen';
|
||||
|
||||
@override
|
||||
String get repeater_refreshAdvertisementSettings =>
|
||||
@@ -2232,10 +2362,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get repeater_commandHelp => 'Help';
|
||||
|
||||
@override
|
||||
String get repeater_clearHistory => 'Verwijder Geschiedenis';
|
||||
String get repeater_clearHistory => 'Geschiedenis Verwijderen';
|
||||
|
||||
@override
|
||||
String get repeater_noCommandsSent => 'Geen commando\'s verzonden nog.';
|
||||
String get repeater_noCommandsSent => 'Nog geen commando\'s verzonden.';
|
||||
|
||||
@override
|
||||
String get repeater_typeCommandOrUseQuick =>
|
||||
@@ -2262,28 +2392,34 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickGetName => 'Haal Naam op';
|
||||
String get repeater_cliQuickGetName => 'Naam opvragen';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickGetRadio => 'Radio ontvangen';
|
||||
String get repeater_cliQuickGetRadio => 'Radio-instellingen opvragen';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickGetTx => 'Krijg TX';
|
||||
String get repeater_cliQuickGetTx => 'TX opvragen';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickNeighbors => 'Buren';
|
||||
String get repeater_cliQuickNeighbors => 'Buren opvragen';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickVersion => 'Versie';
|
||||
String get repeater_cliQuickVersion => 'Versie opvragen';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickAdvertise => 'Advertenties';
|
||||
String get repeater_cliQuickAdvertise => 'Advertenties opvragen';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Tijd';
|
||||
String get repeater_cliQuickClock => 'Tijd opvragen';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Verstuurt een advertentiepakket';
|
||||
String get repeater_cliQuickClockSync => 'Kloksynchronisatie';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Ontdek Buren';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Advertentie uitzenden';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpReboot =>
|
||||
@@ -2555,7 +2691,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get telemetry_voltageLabel => 'Spanning';
|
||||
|
||||
@override
|
||||
String get telemetry_mcuTemperatureLabel => 'MCU Temperatuur';
|
||||
String get telemetry_mcuTemperatureLabel => 'MCU-temperatuur';
|
||||
|
||||
@override
|
||||
String get telemetry_temperatureLabel => 'Temperatuur';
|
||||
@@ -2596,7 +2732,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get neighbors_repeatersNeighbors => 'Herhalingen Buren';
|
||||
String get neighbors_repeatersNeighbors => 'Repeatbburen';
|
||||
|
||||
@override
|
||||
String get neighbors_noData => 'Geen gegevens van buren beschikbaar.';
|
||||
@@ -2895,7 +3031,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get listFilter_latestMessages => 'Recente berichten';
|
||||
|
||||
@override
|
||||
String get listFilter_heardRecently => 'Hoor je onlangs';
|
||||
String get listFilter_heardRecently => 'Recent gezien';
|
||||
|
||||
@override
|
||||
String get listFilter_az => 'A-Z';
|
||||
@@ -3141,7 +3277,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
'Contact uit klembord toevoegen';
|
||||
|
||||
@override
|
||||
String get contacts_ShareContact => 'Kontakt naar Klembord kopiëren';
|
||||
String get contacts_ShareContact => 'Contact naar Klembord kopiëren';
|
||||
|
||||
@override
|
||||
String get contacts_ShareContactZeroHop => 'Contact delen via advertentie';
|
||||
@@ -3342,4 +3478,195 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Weet u zeker dat u alle ontdekte contacten wilt verwijderen?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Gelieve even te wachten voordat u opnieuw verzendt.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Ga naar het oudste ongelezen bericht';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Bij het openen van een chat met ongelezen berichten, scroll dan naar het eerste ongelezen bericht, in plaats van naar het meest recente.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Hongaars';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japanisch';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Koreaans';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Statistieken voor radio en mesh-netwerken';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Statistieken over radio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Verbind met een apparaat om radio-statistieken te bekijken.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Om de statistieken via radio te kunnen gebruiken, is firmware versie 8 of een nieuwere vereist.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Wacht op gegevens…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Ruisfrequentie: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Laatste RSSI-waarde: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Laatste SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'TX-tijd (totaal): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Tijd besteed met RX (totaal): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Ruisfrequentie (dBm) over recente metingen.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Ruisfrequentie: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Radio-statistieken ophalen…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Statistieken over radio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Ruimtelijke ruis, RSSI, SNR en beschikbare tijd';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Vertaling';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Activeer vertaling';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Vertaal inkomende berichten en maak het mogelijk om berichten vooraf te vertalen.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Vertaal voor verzending';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Doeltaal';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Gebruik de taal van de app';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Gedownloade model';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Voorgeprogrammeerd Hugging Face-model';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'URL van de handleiding';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Download het model';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Downloaden...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Werken...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Stoppen';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Het samenvoegen van de gedownloade stukken tot één eindbestand...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Gedownloade modellen';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Model verwijderen';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Vertalingmodel gedownload.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Download is afgebroken.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Download mislukt: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst =>
|
||||
'Voer eerst een URL van een model in.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Toon PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PIN verbergen';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth‑koppelings‑PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Voer PIN in voor $deviceName (laat leeg als er geen is).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Berichtvertaling';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Vertaal voor verzending';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'De berichten worden vertaald voordat ze verzonden worden.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Stuur berichten in de oorspronkelijke, getypte taal.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Vertalen naar $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Opties voor vertaling';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Taal van het systeem';
|
||||
}
|
||||
|
||||
+578
-237
File diff suppressed because it is too large
Load Diff
@@ -398,6 +398,51 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Ações';
|
||||
|
||||
@@ -696,6 +741,49 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Bateria';
|
||||
|
||||
@@ -945,6 +1033,42 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Canais';
|
||||
|
||||
@@ -1114,6 +1238,14 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Ainda não existem mensagens.';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Enviar uma mensagem para $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Enviar uma mensagem para começar';
|
||||
|
||||
@@ -1133,11 +1265,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Localização';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Enviar uma mensagem para $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Digite uma mensagem...';
|
||||
|
||||
@@ -1562,6 +1689,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Outros Nós';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Sobreposições da Chave Repeater';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Prefixo Chave';
|
||||
|
||||
@@ -1605,6 +1735,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Executar Traçado de Caminho';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Retornar ao mesmo caminho.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Remover Último';
|
||||
|
||||
@@ -2293,6 +2426,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Relógio';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Sincronização do Relógio';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Descobrir Vizinhos';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Envia um pacote de anúncios';
|
||||
|
||||
@@ -3354,4 +3493,194 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Tem certeza de que deseja excluir todos os contatos descobertos?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Por favor, aguarde um momento antes de reenviar.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Vá para a mensagem mais antiga não lida';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Ao abrir uma conversa com mensagens não lidas, role para a primeira mensagem não lida, em vez da mais recente.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Húngaro';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonês';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Coreano';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Estatísticas de rádio e malha';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Estatísticas de rádio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Conecte-se a um dispositivo para visualizar estatísticas de rádio.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'As estatísticas de rádio exigem o firmware v8 ou uma versão mais recente.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Aguardando dados…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Nível de ruído: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Último RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Último SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Tempo de transmissão da TX (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Tempo de uso do RX (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Nível de ruído (dBm) em amostras recentes.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Nível de ruído: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Obtendo estatísticas de rádio…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Estatísticas de rádio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Nível de ruído, RSSI, SNR e tempo de transmissão';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Tradução';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Ativar a tradução';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Traduzir mensagens recebidas e permitir a tradução antes do envio.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Traduza antes de enviar';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Controla o estado padrão do ícone de tradução do compositor.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Língua-alvo';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Utilize o idioma da aplicação';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Modelo baixado';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Modelo pré-definido da Hugging Face';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'URL do modelo manual';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Baixar modelo';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Baixando...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Trabalhando...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Pare';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Combinando os fragmentos baixados em um único arquivo...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Modelos baixados';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Excluir modelo';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Modelo de tradução baixado.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Download interrompido.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Falha na descarga: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst => 'Insira primeiro a URL do modelo.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostrar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Ocultar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN de emparelhamento Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Insira o PIN para $deviceName (deixe em branco se não houver).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Tradução da mensagem';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Traduzir antes de enviar';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'As mensagens serão traduzidas antes de serem enviadas.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Envie mensagens no idioma original, conforme digitado.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Traduzir para $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Opções de tradução';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Idioma do sistema';
|
||||
}
|
||||
|
||||
@@ -398,6 +398,51 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
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
|
||||
String get settings_actions => 'Действия';
|
||||
|
||||
@@ -696,6 +741,50 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
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
|
||||
String get appSettings_battery => 'Батарея';
|
||||
|
||||
@@ -944,6 +1033,42 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Каналы';
|
||||
|
||||
@@ -1113,6 +1238,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Сообщений пока нет';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Отправить сообщение $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Отправьте сообщение, чтобы начать';
|
||||
|
||||
@@ -1132,11 +1265,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Местоположение';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Отправить сообщение $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Напишите сообщение...';
|
||||
|
||||
@@ -1564,6 +1692,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Другие ноды';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Перекрытия ключа повтора';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Префикс ключа';
|
||||
|
||||
@@ -1607,6 +1738,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Запустить трассировку пути';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Вернуться обратно по тому же пути';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Удалить последний';
|
||||
|
||||
@@ -2296,6 +2430,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Время';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Синхронизация часов';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Обнаружить Соседей';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Отправляет пакет анонсирования';
|
||||
|
||||
@@ -3367,4 +3507,194 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Вы уверены, что хотите удалить все обнаруженные контакты?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Пожалуйста, подождите немного, прежде чем отправлять сообщение снова.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Перейти к самому старому непрочитанному сообщению';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'При открытии чата с непрочитанными сообщениями, прокрутите страницу, чтобы увидеть первое непрочитанное сообщение, а не последнее.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Венгерский';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Японский';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Корейский';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Статистика радио и беспроводной сети';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Статистика радиовещания';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Подключитесь к устройству, чтобы просмотреть статистику радио.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Для работы радиостатистики требуется установленная версия прошивки v8 или более новая.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Ожидаем данных…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Уровень шума: $noiseDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Последнее значение RSSI: $rssiDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Последнее значение SNR: $snr дБ';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Время эфира на телеканале TX (общее): $seconds секунд';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Общее время использования RX (в секундах): $seconds с';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Уровень шума (дБм) на основе последних измерений.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Уровень шума: $noiseDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Получение данных о радио…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Статистика радиовещания';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Уровень шума, RSSI, SNR и время передачи';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Перевод';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Включить перевод';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Переводить входящие сообщения и позволять предварительный перевод перед отправкой.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Переводить перед отправкой';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Управляет исходным состоянием значка перевода, предоставляемого редактором.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Целевой язык';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Используйте язык приложения';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Загруженная модель';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Предопределенная модель от Hugging Face';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'Ссылка на руководство';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Скачать модель';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Загрузка...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Работа...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Прекратите';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Объединение скачанных фрагментов в один финальный файл...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Загруженные модели';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Удалить модель';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Модель перевода загружена.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Процесс загрузки был прерван.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Не удалось скачать: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst => 'Сначала введите URL модели.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Показать PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Скрыть PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN‑код сопряжения Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Введите PIN‑код для $deviceName (оставьте пустым, если нет).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Перевод сообщения';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Перевести перед отправкой';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Сообщения будут переведены перед отправкой.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Отправляйте сообщения на языке, в котором они были изначально набраны.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Перевести на $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Варианты перевода';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Язык системы';
|
||||
}
|
||||
|
||||
@@ -395,6 +395,49 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Možné akcie';
|
||||
|
||||
@@ -687,6 +730,48 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Batéria';
|
||||
|
||||
@@ -938,6 +1023,41 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Kanály';
|
||||
|
||||
@@ -1106,6 +1226,14 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Zatiaľ žiadne správy.';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Pošli správu $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Pošlite správu na začiatok';
|
||||
|
||||
@@ -1125,11 +1253,6 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Lokalita';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Pošli správu $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Napište správu...';
|
||||
|
||||
@@ -1555,6 +1678,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Ostatné uzly';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Prekrývanie opakovača kľúča';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Päťciferné predpona';
|
||||
|
||||
@@ -1598,6 +1724,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Spustiť trasovaním cesty';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Vráťte sa späť po tej istej ceste.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Odstrániť posledný';
|
||||
|
||||
@@ -2280,6 +2409,12 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Hodiny';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Synchronizácia hodin';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Objaviť susedov';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Odosiela reklamnú balíček.';
|
||||
|
||||
@@ -3338,4 +3473,194 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Ste si istí, že chcete zmazať všetky objavené kontakty?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown => 'Prosím, počkajte chvíľu, než zašlete znova.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread => 'Presk oceň';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Pri otvorení chatu s neprečítanými správami, prejdite do prvého neprečítaného, namiesto poslednej.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Maďarský';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonský';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Kórejský';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Statistiky rádiových a sieťových kanálov';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Štatistiky rádiových vysielaní';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Pripojte sa k zariadeniu, aby ste mohli sledovať štatistiky rádiového vysielania.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Statistické údaje z rádia vyžadujú sprievodný softvér verzie v8 alebo novšej.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Čakám na údaje…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Úroveň hluku: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Posledný údaj RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Posledná hodnota SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Čas vysielania na TX (celkový): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Čas RX (celkový): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Úroveň šumu (dBm) pre posledné vzorky.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Úroveň hluku: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Získavanie údajov o rádiu…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Štatistiky rádiových vysielaní';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Úroveň hluku, RSSI, SNR a časové rozloženie';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Preklad';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Aktivovať preklad';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Prekladajte prichádzajúce správy a umožnite ich preklad pred odoslaním.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Preložte pred odeslaním';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Riadi výchoce stav ikony pre preklad, ktorú používa program.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Cieľový jazyk';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Použite jazyk aplikácie';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Stiahnutý model';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Prednastavený model od Hugging Face';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel =>
|
||||
'Odkaz na manuál (v elektronickej forme)';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Stiahnuť model';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Stiahnutie...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Práca...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Zastavte';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Sliečenie stiahnutých častí do konečného súboru...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Stiahnuté modely';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Odstrániť model';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Model pre preklad bol stiahnutý.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Stiahnutie bolo prerušené.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Neúspešné stiahnutie: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst =>
|
||||
'Najprv zadajte URL pre konkrétny model.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Zobraziť PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Skryť PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN pre párovanie cez Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Zadajte PIN pre $deviceName (ak neexistuje, nechajte prázdne).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Preklad textu';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Preložte pred odeslaním';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Správy budú preložené, než budú odoslané.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Posielajte správy v pôvodnej písanom jazyku.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Preložte do $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Možnosti prekladania';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Jazyk systému';
|
||||
}
|
||||
|
||||
@@ -393,6 +393,50 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Akcije';
|
||||
|
||||
@@ -687,6 +731,49 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Baterija';
|
||||
|
||||
@@ -934,6 +1021,41 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Kanali';
|
||||
|
||||
@@ -1102,6 +1224,14 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Še ni sporočil.';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Pošlji sporočilo $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Pošlji sporočilo za začetek.';
|
||||
|
||||
@@ -1122,11 +1252,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Lokacija';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Pošlji sporočilo $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Vnesi sporočilo...';
|
||||
|
||||
@@ -1549,6 +1674,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Druge vozlišča';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Predpona ključa';
|
||||
|
||||
@@ -1591,6 +1719,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Zaženi sledenje poti';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Vrni se nazaj po isti poti.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Odstrani Zadnji';
|
||||
|
||||
@@ -2281,6 +2412,12 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Ura';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Usklajevanje ure';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Odkrijte sosede';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Pošlje paket oglasov';
|
||||
|
||||
@@ -3339,4 +3476,196 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Ste prepričani, da želite izbrisati vse odkrite kontakte?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Prosimo, počakajte trenutek, preden pošljete ponovno.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Pritisnite za najstarejše nepročitano sporočilo';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Ko odpirate klepet z neprebranimi sporočili, se premaknite na prvo neprebrano sporočilo, namesto najnovejšega.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Madžarski';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonski';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Korejski';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Statistike za radio in mrežo';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Radijske statistike';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Povežite se z napravo, da si ogledate statistiko o radiju.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Statistika za radio zahteva združljivo programsko opremo v8 ali kasnejše.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Čakam na podatke…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Število šuma: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Najkasnejše vrednost RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Najkasnejše vrednost SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Čas na TX (skupno): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Čas, namenjen RX-ju (skupno): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Ravnovredna raven šuma (dBm) za nedavne vzorce.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Število šuma: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Prejemanje statistike o radiju…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Radijske statistike';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Prevod';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Omogočite prevod';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Prevedite vstopne sporočila in omogočite predhodno prevajanje.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Preprištejte, preden pošljete';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Ciljna jezika';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Uporabite jezik aplikacije';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Naložen model';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Prednastavljeni model Hugging Face';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'URL za ročni model';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Prenesite model';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Izvajanje...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Delo...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Prekliji';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Sklapljanje prenesenih delov v končni datoteko...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Naloženi modeli';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Izbrisati model';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded =>
|
||||
'Model za prevajanje je bil naložen.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Prenos je bil prekinjen.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Izgovoritev ni bila uspešna: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst => 'Najprej vnesite URL model.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Prikaži PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Skrij PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth PIN za seznanjanje';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Vnesite PIN za $deviceName (pustite prazno, če ga ni).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Prevod sporočila';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending =>
|
||||
'Preprištejte, preden pošljete';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Vsebina sporočil bo prevedena, preden jih pošljemo.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Pošljite sporočila v originalnem tipkanem jeziku.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Prevesti v $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Možnosti prevoda';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Jezik sistema';
|
||||
}
|
||||
|
||||
@@ -392,6 +392,49 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Åtgärder';
|
||||
|
||||
@@ -682,6 +725,48 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'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
|
||||
String get appSettings_battery => 'Batteri';
|
||||
|
||||
@@ -930,6 +1015,40 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Kanaler';
|
||||
|
||||
@@ -1098,6 +1217,14 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Inga meddelanden ännu';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Skicka ett meddelande till $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart =>
|
||||
'Skicka ett meddelande för att komma igång';
|
||||
@@ -1119,11 +1246,6 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Plats';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Skicka ett meddelande till $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Skriv ett meddelande...';
|
||||
|
||||
@@ -1545,6 +1667,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Andra noder';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Repeater-nyckelöverlappningar';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Nyckelprefix';
|
||||
|
||||
@@ -1588,6 +1713,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Kör spårsökning';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Gå tillbaka på samma väg';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Ta bort sista';
|
||||
|
||||
@@ -2269,6 +2397,12 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Klocka';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Synkronisera klocka';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Upptäck grannar';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Skickar ett annonspaket';
|
||||
|
||||
@@ -3319,4 +3453,196 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Är du säker på att du vill ta bort alla upptäckta kontakter?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Vänligen vänta en stund innan du skickar igen.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Gå direkt till det äldsta, obesvarade meddelandet';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'När du öppnar en chatt med oinlästa meddelanden, scrolla till det första oinlästa meddelandet istället för det senaste.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Ungerskt';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japanska';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Koreanska';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Radio- och mesh-statistik';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Radiostation';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Anslut till en enhet för att visa radiostatistik.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Radio statistik kräver kompatibel firmware version 8 eller senare.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Väntar på data…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Bakgrundsnivå: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Senaste RSSI-värde: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Senaste SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'TX-tid (total): $seconds sekunder';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'RX-tid (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Ljudnivå (dBm) baserat på de senaste mätningarna.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Bakgrundsnivå: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Hämtar radiostatistik…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Radiostation';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Bakgrundsnivå, RSSI, SNR och tillgänglig tid';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Översättning';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Aktivera översättning';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Översätt inkommande meddelanden och möjliggör översättning före avsändning.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Översätt innan du skickar';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Styr standardtillståndet för kompositorns översättningsikon.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Målmedvetet språk';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Använd appens språk';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Nedladdad modell';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Fördefinierat Hugging Face-modell';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => 'Manualens URL';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Ladda ner modellen';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Nedladdning...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Arbeta...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Stopp';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Slå samman de nedladdade delarna till en slutlig fil...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Nedladdade modeller';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Ta bort modell';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded =>
|
||||
'Översättningsmodellen har laddats ner.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Nedladdningen avbruten.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Nedladdning misslyckades: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst =>
|
||||
'Ange först en URL för en specifik modell.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Visa PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Dölj PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth‑parnings‑PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Ange PIN för $deviceName (lämna tomt om ingen).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Meddelandets översättning';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => 'Översätt innan du skickar';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Meddelandena kommer att översättas innan de skickas.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Skicka meddelanden på det ursprungliga, stavade språket.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Översätt till $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Översättningsalternativ';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Språk för systemet';
|
||||
}
|
||||
|
||||
@@ -395,6 +395,50 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => 'Дії';
|
||||
|
||||
@@ -692,6 +736,49 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
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
|
||||
String get appSettings_battery => 'Батарея';
|
||||
|
||||
@@ -940,6 +1027,42 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => 'Канали';
|
||||
|
||||
@@ -1107,6 +1230,14 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => 'Поки немає повідомлень.';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Надіслати повідомлення $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => 'Надішліть повідомлення, щоб почати';
|
||||
|
||||
@@ -1127,11 +1258,6 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => 'Розташування';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return 'Надіслати повідомлення $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => 'Введіть повідомлення...';
|
||||
|
||||
@@ -1561,6 +1687,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Інші вузли';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Перекриття ключа повторювача';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Префікс ключа';
|
||||
|
||||
@@ -1604,6 +1733,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Виконати трасування шляху';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Повернутися назад тим же шляхом';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Видалити останній';
|
||||
|
||||
@@ -2298,6 +2430,12 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Годинник';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Синхронізація годинника';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Відкрити сусідів';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Надсилає пакет оголошення';
|
||||
|
||||
@@ -3372,4 +3510,196 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Ви впевнені, що хочете видалити всі виявлені контакти?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Будь ласка, зачекайте трохи, перш ніж відправляти знову.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Перейти до найстарішого непрочитаного повідомлення';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'При відкритті чату з не прочитаними повідомленнями, прокрутіть до першого не прочитаного повідомлення, а не до останнього.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Угорський';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Японська';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Кореєська';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Статистика радіо та мережі';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Дані про радіостанції';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Підключіться до пристрою, щоб переглядати статистику радіопередач.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Статистика радіо приймача вимагає супутнього програмного забезпечення версії 8 або новішої.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Очікую на отримання даних…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Рівень шуму: $noiseDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Останній показник RSSI: $rssiDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Останній показник SNR: $snr дБ';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Час трансляції на телеканалі TX (загальний): $seconds секунд';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Загальний час використання RX: $seconds секунд';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Рівень шуму (дБм) на основі останніх вимірювань.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Рівень шуму: $noiseDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Отримано статистику радіо…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Дані про радіостанції';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.';
|
||||
|
||||
@override
|
||||
String get translation_title => 'Переклад';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => 'Увімкнути переклад';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle =>
|
||||
'Перекладати отримані повідомлення та дозволяти попередній переклад перед відправкою.';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => 'Перекладіть перед відправкою';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle =>
|
||||
'Контролює стан ікон перекладу, який використовується за замовчуванням.';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => 'Цільова мова';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => 'Використовуйте мову додатку';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => 'Завантажений шаблон';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel =>
|
||||
'Заздалегідь налаштований модель від Hugging Face';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel =>
|
||||
'Посилання на веб-сторінку з інструкцією';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => 'Завантажити модель';
|
||||
|
||||
@override
|
||||
String get translation_downloading => 'Завантаження...';
|
||||
|
||||
@override
|
||||
String get translation_working => 'Працюю...';
|
||||
|
||||
@override
|
||||
String get translation_stop => 'Припинити';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks =>
|
||||
'Об\'єднання завантажених фрагментів у кінцевий файл...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => 'Завантажені моделі';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => 'Видалити модель';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => 'Модель перекладу завантажена.';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => 'Завантаження призупинено.';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return 'Не вдалося завантажити: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst => 'Спочатку введіть URL моделі.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Показати PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Приховати PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN‑код спарювання Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Введіть PIN для $deviceName (залиште порожнім, якщо його немає).';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => 'Переклад повідомлення';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending =>
|
||||
'Перекладіть перед відправкою';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint =>
|
||||
'Повідомлення будуть перекладені перед відправленням.';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint =>
|
||||
'Надсилайте повідомлення, використовуючи оригінальний текстовий формат.';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return 'Перекласти на $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => 'Варіанти перекладу';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => 'Мова системи';
|
||||
}
|
||||
|
||||
@@ -374,6 +374,47 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get settings_actions => '操作';
|
||||
|
||||
@@ -648,6 +689,43 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get appSettings_battery => '电池';
|
||||
|
||||
@@ -886,6 +964,39 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
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
|
||||
String get channels_title => '频道';
|
||||
|
||||
@@ -1050,6 +1161,14 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get chat_noMessages => '暂无消息';
|
||||
|
||||
@override
|
||||
String get chat_sendMessage => 'Send message';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return '发送消息给 $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_sendMessageToStart => '发送消息开始对话';
|
||||
|
||||
@@ -1069,11 +1188,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get chat_location => '位置';
|
||||
|
||||
@override
|
||||
String chat_sendMessageTo(String contactName) {
|
||||
return '发送消息给 $contactName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chat_typeMessage => '输入消息...';
|
||||
|
||||
@@ -1471,6 +1585,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => '其他节点';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => '重复键重叠';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => '关键字前缀';
|
||||
|
||||
@@ -1513,6 +1630,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => '运行路径追踪';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => '沿着相同的路径返回';
|
||||
|
||||
@override
|
||||
String get map_removeLast => '移除最后一个';
|
||||
|
||||
@@ -2160,6 +2280,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => '时钟';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => '同步时钟';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => '发现邻居';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => '发送广播包';
|
||||
|
||||
@@ -3105,4 +3231,182 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent => '您确定要删除所有发现的联系人吗?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown => '请稍等片刻后再尝试发送。';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread => '跳转到最旧、未读的文章';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'在打开包含未读消息的聊天时,请滚动到第一个未读消息,而不是最新的消息。';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => '匈牙利';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => '日语';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => '韩语';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => '无线电和网状结构统计数据';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => '广播统计数据';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected => '连接到设备以查看收音机统计信息。';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld => '使用无线电统计功能需要配合使用 v8 或更高版本的固件。';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => '正在等待数据…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return '噪声水平:$noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return '上次 RSSI 值:$rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return '上次 SNR:$snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'TX 频道播出时间(总时长):$seconds 秒';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'RX 使用时长(总时长):$seconds 秒';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption => '近期的噪声水平(dBm)。';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return '噪声水平:$noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => '正在获取收音机数据…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => '广播统计数据';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle => '噪声水平、RSSI、信噪比和空中时间';
|
||||
|
||||
@override
|
||||
String get translation_title => '翻译';
|
||||
|
||||
@override
|
||||
String get translation_enableTitle => '启用翻译功能';
|
||||
|
||||
@override
|
||||
String get translation_enableSubtitle => '翻译收到的消息,并允许在发送前进行翻译。';
|
||||
|
||||
@override
|
||||
String get translation_composerTitle => '在发送之前进行翻译';
|
||||
|
||||
@override
|
||||
String get translation_composerSubtitle => '控制作曲家翻译图标的默认状态。';
|
||||
|
||||
@override
|
||||
String get translation_targetLanguage => '目标语言';
|
||||
|
||||
@override
|
||||
String get translation_useAppLanguage => '使用应用程序语言';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModelLabel => '下载的模型';
|
||||
|
||||
@override
|
||||
String get translation_presetModelLabel => '预设的 Hugging Face 模型';
|
||||
|
||||
@override
|
||||
String get translation_manualUrlLabel => '手动模型网址';
|
||||
|
||||
@override
|
||||
String get translation_downloadModel => '下载模型';
|
||||
|
||||
@override
|
||||
String get translation_downloading => '正在下载...';
|
||||
|
||||
@override
|
||||
String get translation_working => '工作中...';
|
||||
|
||||
@override
|
||||
String get translation_stop => '停止';
|
||||
|
||||
@override
|
||||
String get translation_mergingChunks => '将下载的片段合并成最终文件...';
|
||||
|
||||
@override
|
||||
String get translation_downloadedModels => '下载的模型';
|
||||
|
||||
@override
|
||||
String get translation_deleteModel => '删除模型';
|
||||
|
||||
@override
|
||||
String get translation_modelDownloaded => '翻译模型已下载。';
|
||||
|
||||
@override
|
||||
String get translation_downloadStopped => '下载已停止。';
|
||||
|
||||
@override
|
||||
String translation_downloadFailed(String error) {
|
||||
return '下载失败:$error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_enterUrlFirst => '首先,请输入模型的 URL。';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => '显示PIN码';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => '隐藏 PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => '蓝牙配对 PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return '输入 $deviceName 的 PIN 码(如果为空,则留空)。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_messageTranslation => '消息翻译';
|
||||
|
||||
@override
|
||||
String get translation_translateBeforeSending => '在发送前进行翻译';
|
||||
|
||||
@override
|
||||
String get translation_composerEnabledHint => '消息将在发送前进行翻译。';
|
||||
|
||||
@override
|
||||
String get translation_composerDisabledHint => '使用原始的打字方式发送消息。';
|
||||
|
||||
@override
|
||||
String translation_translateTo(String language) {
|
||||
return '翻译成 $language';
|
||||
}
|
||||
|
||||
@override
|
||||
String get translation_translationOptions => '翻译选项';
|
||||
|
||||
@override
|
||||
String get translation_systemLanguage => '系统语言';
|
||||
}
|
||||
|
||||
+220
-48
@@ -84,7 +84,7 @@
|
||||
"settings_appSettings": "App Instellingen",
|
||||
"settings_appSettingsSubtitle": "Notificaties, berichten en kaartinstellingen",
|
||||
"settings_nodeSettings": "Node Instellingen",
|
||||
"settings_nodeName": "Node Naam",
|
||||
"settings_nodeName": "Nodenaam",
|
||||
"settings_nodeNameNotSet": "Niet ingesteld",
|
||||
"settings_nodeNameHint": "Voer nodenaam in",
|
||||
"settings_nodeNameUpdated": "Naam bijgewerkt",
|
||||
@@ -107,13 +107,13 @@
|
||||
"settings_sendAdvertisement": "Verzend Advertentie",
|
||||
"settings_sendAdvertisementSubtitle": "Nu aanwezigheid uitzenden",
|
||||
"settings_advertisementSent": "Advertentie verzonden",
|
||||
"settings_syncTime": "Synchronisatie Tijd",
|
||||
"settings_syncTime": "Tijd Synchroniseren",
|
||||
"settings_syncTimeSubtitle": "Stel de apparaatklok in op de tijd van de telefoon.",
|
||||
"settings_timeSynchronized": "Tijdsynchronisatie",
|
||||
"settings_refreshContacts": "Contacten vernieuwen",
|
||||
"settings_refreshContactsSubtitle": "Contactlijst opnieuw laden van het apparaat",
|
||||
"settings_rebootDevice": "Apparaat opnieuw opstarten",
|
||||
"settings_rebootDeviceSubtitle": "Herstart het MeshCore apparaat",
|
||||
"settings_rebootDeviceSubtitle": "Herstart het MeshCore-apparaat",
|
||||
"settings_rebootDeviceConfirm": "Ben je er zeker van dat je het apparaat opnieuw wilt opstarten? Je wordt losgekoppeld.",
|
||||
"settings_debug": "Debug",
|
||||
"settings_bleDebugLog": "BLE Debug Log",
|
||||
@@ -145,7 +145,7 @@
|
||||
"settings_bandwidth": "Bandbreedte",
|
||||
"settings_spreadingFactor": "Spreadsnelheid",
|
||||
"settings_codingRate": "Codeertarief",
|
||||
"settings_txPower": "TX Vermogen (dBm)",
|
||||
"settings_txPower": "TX-Vermogen (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Ongeldige TX-vermogen (0-22 dBm)",
|
||||
"settings_error": "Fout: {message}",
|
||||
@@ -232,11 +232,11 @@
|
||||
"appSettings_mapTimeFilter": "Filter tijd op kaart",
|
||||
"appSettings_showNodesDiscoveredWithin": "Toon nodes ontdekt binnen:",
|
||||
"appSettings_allTime": "Altijd",
|
||||
"appSettings_lastHour": "Laat uur",
|
||||
"appSettings_last6Hours": "laatste 6 uur",
|
||||
"appSettings_last24Hours": "De laatste 24 uur",
|
||||
"appSettings_lastWeek": "Laatste week",
|
||||
"appSettings_offlineMapCache": "Offline Kaarten Cache",
|
||||
"appSettings_lastHour": "Afgelopen uur",
|
||||
"appSettings_last6Hours": "Afgelopen 6 uur",
|
||||
"appSettings_last24Hours": "Afgelopen 24 uur",
|
||||
"appSettings_lastWeek": "Afgelopen week",
|
||||
"appSettings_offlineMapCache": "Offline Kaartcache",
|
||||
"appSettings_noAreaSelected": "Geen gebied geselecteerd",
|
||||
"appSettings_areaSelectedZoom": "Geselecteerd gebied (zoom {minZoom}-{maxZoom})",
|
||||
"@appSettings_areaSelectedZoom": {
|
||||
@@ -682,7 +682,7 @@
|
||||
"map_showSharedMarkers": "Toon gedeelde markeringen",
|
||||
"map_lastSeenTime": "Laatste Bekeken Tijd",
|
||||
"map_sharedPin": "Gedeelde pin",
|
||||
"map_joinRoom": "Sluit Kamer",
|
||||
"map_joinRoom": "Kamer Toetreden",
|
||||
"map_manageRepeater": "Beheer Repeater",
|
||||
"mapCache_title": "Offline Kaarten Cache",
|
||||
"mapCache_selectAreaFirst": "Select een gebied om eerst in de cache op te slaan",
|
||||
@@ -878,7 +878,7 @@
|
||||
"path_tooLong": "Pad is te lang. Maximaal 64 sprongen zijn toegestaan.",
|
||||
"path_setPath": "Stel Pad in",
|
||||
"repeater_management": "Beheer Repeaters",
|
||||
"repeater_managementTools": "Beheerinstrumenten",
|
||||
"repeater_managementTools": "Beheerfuncties",
|
||||
"repeater_status": "Status",
|
||||
"repeater_statusSubtitle": "Status, statistieken en buren bekijken",
|
||||
"repeater_telemetry": "Telemetry",
|
||||
@@ -912,8 +912,8 @@
|
||||
"repeater_lastRssi": "Laatste RSSI",
|
||||
"repeater_lastSnr": "Laatste SNR",
|
||||
"repeater_noiseFloor": "Ruisvloer",
|
||||
"repeater_txAirtime": "TX Airtime",
|
||||
"repeater_rxAirtime": "RX Airtime",
|
||||
"repeater_txAirtime": "TX-zendtijd",
|
||||
"repeater_rxAirtime": "RX-zendtijd",
|
||||
"repeater_packetStatistics": "Pakketstatistieken",
|
||||
"repeater_sent": "Verzonden",
|
||||
"repeater_received": "Ontvangen",
|
||||
@@ -982,14 +982,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_settingsTitle": "Repeater Instellingen",
|
||||
"repeater_settingsTitle": "Repeaterinstellingen",
|
||||
"repeater_basicSettings": "Basisinstellingen",
|
||||
"repeater_repeaterName": "Repeaternaam",
|
||||
"repeater_repeaterNameHelper": "Weergave naam voor deze repeater",
|
||||
"repeater_repeaterNameHelper": "Weergavenaam voor deze repeater",
|
||||
"repeater_adminPassword": "Admin wachtwoord",
|
||||
"repeater_adminPasswordHelper": "Volledige toegangspaswoord",
|
||||
"repeater_guestPassword": "Wachtwoord Gast",
|
||||
"repeater_guestPasswordHelper": "Leesbeheer wachtwoord",
|
||||
"repeater_adminPasswordHelper": "Wachtwoord administratortoegang",
|
||||
"repeater_guestPassword": "Gast wachtwoord",
|
||||
"repeater_guestPasswordHelper": "Wachtwoord gasttoegen",
|
||||
"repeater_radioSettings": "Radio Instellingen",
|
||||
"repeater_frequencyMhz": "Frequentie (MHz)",
|
||||
"repeater_frequencyHelper": "300-2500 MHz",
|
||||
@@ -998,7 +998,7 @@
|
||||
"repeater_bandwidth": "Bandbreedte",
|
||||
"repeater_spreadingFactor": "Spreidingsfactor",
|
||||
"repeater_codingRate": "Codeertarief",
|
||||
"repeater_locationSettings": "Locatie Instellingen",
|
||||
"repeater_locationSettings": "Locatie-instellingen",
|
||||
"repeater_latitude": "Breedtegraad",
|
||||
"repeater_latitudeHelper": "Graadseconden (bijv. 37.7749)",
|
||||
"repeater_longitude": "Lengtegraad",
|
||||
@@ -1008,9 +1008,9 @@
|
||||
"repeater_packetForwardingSubtitle": "Repeater instellen om pakketten door te sturen",
|
||||
"repeater_guestAccess": "Toegang voor Gasten",
|
||||
"repeater_guestAccessSubtitle": "Toegestane leesbeheer toegang voor gasten.",
|
||||
"repeater_privacyMode": "Privacy Modus",
|
||||
"repeater_privacyMode": "Privacymodus",
|
||||
"repeater_privacyModeSubtitle": "Naam/locatie verbergen in advertenties",
|
||||
"repeater_advertisementSettings": "Advertentie Instellingen",
|
||||
"repeater_advertisementSettings": "Advertentie-instellingen",
|
||||
"repeater_localAdvertInterval": "Lokale Advertentie Interval",
|
||||
"repeater_localAdvertIntervalMinutes": "{minutes} minuten",
|
||||
"@repeater_localAdvertIntervalMinutes": {
|
||||
@@ -1073,7 +1073,7 @@
|
||||
"repeater_refreshLocationSettings": "Instellingen Locatie Vernieuwen",
|
||||
"repeater_refreshPacketForwarding": "Vernieuwen Pakket Doorversturing",
|
||||
"repeater_refreshGuestAccess": "Toegang Gast Vernieuwen",
|
||||
"repeater_refreshPrivacyMode": "Privacy Mode vernieuwen",
|
||||
"repeater_refreshPrivacyMode": "Privacymode vernieuwen",
|
||||
"repeater_refreshAdvertisementSettings": "Instellingen Advertentie Bijwerken",
|
||||
"repeater_refreshed": "{label} is vernieuwd",
|
||||
"@repeater_refreshed": {
|
||||
@@ -1094,8 +1094,8 @@
|
||||
"repeater_cliTitle": "Repeater CLI",
|
||||
"repeater_debugNextCommand": "Debug Volgende Commando",
|
||||
"repeater_commandHelp": "Help",
|
||||
"repeater_clearHistory": "Verwijder Geschiedenis",
|
||||
"repeater_noCommandsSent": "Geen commando's verzonden nog.",
|
||||
"repeater_clearHistory": "Geschiedenis Verwijderen",
|
||||
"repeater_noCommandsSent": "Nog geen commando's verzonden.",
|
||||
"repeater_typeCommandOrUseQuick": "Typ een opdracht hieronder of gebruik snelle commando's",
|
||||
"repeater_enterCommandHint": "Voer bevel in...",
|
||||
"repeater_previousCommand": "Vorige opdracht",
|
||||
@@ -1110,14 +1110,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_cliQuickGetName": "Haal Naam op",
|
||||
"repeater_cliQuickGetRadio": "Radio ontvangen",
|
||||
"repeater_cliQuickGetTx": "Krijg TX",
|
||||
"repeater_cliQuickNeighbors": "Buren",
|
||||
"repeater_cliQuickVersion": "Versie",
|
||||
"repeater_cliQuickAdvertise": "Advertenties",
|
||||
"repeater_cliQuickClock": "Tijd",
|
||||
"repeater_cliHelpAdvert": "Verstuurt een advertentiepakket",
|
||||
"repeater_cliQuickGetName": "Naam opvragen",
|
||||
"repeater_cliQuickGetRadio": "Radio-instellingen opvragen",
|
||||
"repeater_cliQuickGetTx": "TX opvragen",
|
||||
"repeater_cliQuickNeighbors": "Buren opvragen",
|
||||
"repeater_cliQuickVersion": "Versie opvragen",
|
||||
"repeater_cliQuickAdvertise": "Advertenties opvragen",
|
||||
"repeater_cliQuickClock": "Tijd opvragen",
|
||||
"repeater_cliHelpAdvert": "Advertentie uitzenden",
|
||||
"repeater_cliHelpReboot": "Herstart het apparaat. (let op, je krijgt mogelijk een 'Timeout', wat normaal is)",
|
||||
"repeater_cliHelpClock": "Toont de huidige tijd per apparaat's klok.",
|
||||
"repeater_cliHelpPassword": "Stelt een nieuw beheerderswachtwoord in voor het apparaat.",
|
||||
@@ -1203,7 +1203,7 @@
|
||||
},
|
||||
"telemetry_batteryLabel": "Batterij",
|
||||
"telemetry_voltageLabel": "Spanning",
|
||||
"telemetry_mcuTemperatureLabel": "MCU Temperatuur",
|
||||
"telemetry_mcuTemperatureLabel": "MCU-temperatuur",
|
||||
"telemetry_temperatureLabel": "Temperatuur",
|
||||
"telemetry_currentLabel": "Huidig",
|
||||
"telemetry_batteryValue": "{percent}% / {volts}V",
|
||||
@@ -1346,7 +1346,7 @@
|
||||
"listFilter_tooltip": "Filteren en sorteren",
|
||||
"listFilter_sortBy": "Sorteren door",
|
||||
"listFilter_latestMessages": "Recente berichten",
|
||||
"listFilter_heardRecently": "Hoor je onlangs",
|
||||
"listFilter_heardRecently": "Recent gezien",
|
||||
"listFilter_az": "A-Z",
|
||||
"listFilter_filters": "Filters",
|
||||
"listFilter_all": "Alles",
|
||||
@@ -1363,20 +1363,20 @@
|
||||
}
|
||||
},
|
||||
"repeater_neighbors": "Buren",
|
||||
"repeater_neighborsSubtitle": "Bekijk nul hops buren.",
|
||||
"repeater_neighborsSubtitle": "Bekijk nul-hopsburen.",
|
||||
"neighbors_receivedData": "Ontvangen Buurdata",
|
||||
"neighbors_requestTimedOut": "Buren vragen om tijdelijk uitgeschakeld.",
|
||||
"neighbors_errorLoading": "Fout bij het laden van buren: {error}",
|
||||
"neighbors_repeatersNeighbors": "Herhalingen Buren",
|
||||
"neighbors_repeatersNeighbors": "Repeatbburen",
|
||||
"neighbors_noData": "Geen gegevens van buren beschikbaar.",
|
||||
"channels_createPrivateChannelDesc": "Beveiligd met een geheime sleutel.",
|
||||
"channels_createPrivateChannel": "Maak een Privé Kanaal",
|
||||
"channels_joinPrivateChannel": "Sluit een Privé Kanaal aan",
|
||||
"channels_joinPrivateChannelDesc": "Handmatig een geheime sleutel invoeren.",
|
||||
"channels_joinPublicChannel": "Sluit het Open Kanaal",
|
||||
"channels_joinPublicChannelDesc": "Iedereen kan dit kanaal aanmelden.",
|
||||
"channels_joinHashtagChannel": "Sluit een Hashtag Kanaal",
|
||||
"channels_joinHashtagChannelDesc": "Iedereen kan lid worden van hashtag-kanalen.",
|
||||
"channels_createPrivateChannel": "PrivéKanaal Aanmaken",
|
||||
"channels_joinPrivateChannel": "PrivéKanaal Toetreden",
|
||||
"channels_joinPrivateChannelDesc": "Voer handmatig een geheime sleutel in.",
|
||||
"channels_joinPublicChannel": "Publiek Kanaal Toetreden",
|
||||
"channels_joinPublicChannelDesc": "Iedereen kan toetreden tot dit kanaal.",
|
||||
"channels_joinHashtagChannel": "Hashtag-kanaal Aanmaken",
|
||||
"channels_joinHashtagChannelDesc": "Iedereen kan toetreden tot hashtag-kanalen.",
|
||||
"channels_scanQrCode": "Scan een QR-code",
|
||||
"channels_scanQrCodeComingSoon": "Komt later",
|
||||
"channels_enterHashtag": "Voer hashtag in",
|
||||
@@ -1574,7 +1574,7 @@
|
||||
"contacts_zeroHopContactAdvertSent": "Contact verzonden via advertentie",
|
||||
"contacts_contactAdvertCopied": "Reclame gekopieerd naar Klembord.",
|
||||
"contacts_contactAdvertCopyFailed": "Kopiëren van advertentie naar Clipboard is mislukt.",
|
||||
"contacts_ShareContact": "Kontakt naar Klembord kopiëren",
|
||||
"contacts_ShareContact": "Contact naar Klembord kopiëren",
|
||||
"contacts_ShareContactZeroHop": "Contact delen via advertentie",
|
||||
"contacts_zeroHopContactAdvertFailed": "Mislukt om contact te verzenden",
|
||||
"notification_activityTitle": "MeshCore Activiteit",
|
||||
@@ -1612,8 +1612,8 @@
|
||||
"snrIndicator_lastSeen": "Laatst gezien",
|
||||
"snrIndicator_nearByRepeaters": "Nabije herhalingseenheden",
|
||||
"chat_ShowAllPaths": "Toon alle paden",
|
||||
"settings_clientRepeat": "Herhalen: Afgekoppeld",
|
||||
"settings_clientRepeatSubtitle": "Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.",
|
||||
"settings_clientRepeat": "Off-Grid Herhalen",
|
||||
"settings_clientRepeatSubtitle": "Laat dit apparaat de berichten van andere apparaten doorsturen.",
|
||||
"settings_clientRepeatFreqWarning": "Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist.",
|
||||
"settings_aboutOpenMeteoAttribution": "LOS-hoogtegegevens: Open-Meteo (CC BY 4.0)",
|
||||
"appSettings_unitsTitle": "Eenheden",
|
||||
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
||||
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
||||
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
|
||||
"map_setAsMyLocation": "Stel dit in als mijn locatie"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Herhalingssleutel overlapt",
|
||||
"map_runTraceWithReturnPath": "Terugkeren op hetzelfde pad.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnread": "Ga naar het oudste ongelezen bericht",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Bij het openen van een chat met ongelezen berichten, scroll dan naar het eerste ongelezen bericht, in plaats van naar het meest recente.",
|
||||
"chat_sendCooldown": "Gelieve even te wachten voordat u opnieuw verzendt.",
|
||||
"appSettings_languageHu": "Hongaars",
|
||||
"appSettings_languageJa": "Japanisch",
|
||||
"appSettings_languageKo": "Koreaans",
|
||||
"radioStats_tooltip": "Statistieken voor radio en mesh-netwerken",
|
||||
"radioStats_screenTitle": "Statistieken over radio",
|
||||
"radioStats_notConnected": "Verbind met een apparaat om radio-statistieken te bekijken.",
|
||||
"radioStats_firmwareTooOld": "Om de statistieken via radio te kunnen gebruiken, is firmware versie 8 of een nieuwere vereist.",
|
||||
"radioStats_waiting": "Wacht op gegevens…",
|
||||
"radioStats_noiseFloor": "Ruisfrequentie: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Laatste RSSI-waarde: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Laatste SNR: {snr} dB",
|
||||
"radioStats_txAir": "TX-tijd (totaal): {seconds} s",
|
||||
"radioStats_rxAir": "Tijd besteed met RX (totaal): {seconds} s",
|
||||
"radioStats_chartCaption": "Ruisfrequentie (dBm) over recente metingen.",
|
||||
"radioStats_stripNoise": "Ruisfrequentie: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Radio-statistieken ophalen…",
|
||||
"radioStats_settingsTile": "Statistieken over radio",
|
||||
"radioStats_settingsSubtitle": "Ruimtelijke ruis, RSSI, SNR en beschikbare tijd",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_enableSubtitle": "Vertaal inkomende berichten en maak het mogelijk om berichten vooraf te vertalen.",
|
||||
"translation_enableTitle": "Activeer vertaling",
|
||||
"translation_title": "Vertaling",
|
||||
"translation_composerTitle": "Vertaal voor verzending",
|
||||
"translation_composerSubtitle": "Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.",
|
||||
"translation_useAppLanguage": "Gebruik de taal van de app",
|
||||
"translation_targetLanguage": "Doeltaal",
|
||||
"translation_downloadedModelLabel": "Gedownloade model",
|
||||
"translation_presetModelLabel": "Voorgeprogrammeerd Hugging Face-model",
|
||||
"translation_manualUrlLabel": "URL van de handleiding",
|
||||
"translation_downloadModel": "Download het model",
|
||||
"translation_downloading": "Downloaden...",
|
||||
"translation_working": "Werken...",
|
||||
"translation_mergingChunks": "Het samenvoegen van de gedownloade stukken tot één eindbestand...",
|
||||
"translation_stop": "Stoppen",
|
||||
"translation_downloadedModels": "Gedownloade modellen",
|
||||
"translation_deleteModel": "Model verwijderen",
|
||||
"translation_modelDownloaded": "Vertalingmodel gedownload.",
|
||||
"translation_downloadStopped": "Download is afgebroken.",
|
||||
"translation_downloadFailed": "Download mislukt: {error}",
|
||||
"translation_enterUrlFirst": "Voer eerst een URL van een model in.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerDisabledHint": "Stuur berichten in de oorspronkelijke, getypte taal.",
|
||||
"translation_translateBeforeSending": "Vertaal voor verzending",
|
||||
"translation_composerEnabledHint": "De berichten worden vertaald voordat ze verzonden worden.",
|
||||
"translation_messageTranslation": "Berichtvertaling",
|
||||
"translation_translationOptions": "Opties voor vertaling",
|
||||
"translation_systemLanguage": "Taal van het systeem",
|
||||
"translation_translateTo": "Vertalen naar {language}",
|
||||
"scanner_linuxPairingShowPin": "Toon PIN",
|
||||
"scanner_linuxPairingHidePin": "PIN verbergen",
|
||||
"scanner_linuxPairingPinPrompt": "Voer PIN in voor {deviceName} (laat leeg als er geen is).",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth‑koppelings‑PIN",
|
||||
"repeater_cliQuickDiscovery": "Ontdek Buren",
|
||||
"repeater_cliQuickClockSync": "Kloksynchronisatie"
|
||||
}
|
||||
+438
-228
File diff suppressed because it is too large
Load Diff
+174
-2
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
||||
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
|
||||
"map_setAsMyLocation": "Defina minha localização"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Sobreposições da Chave Repeater",
|
||||
"map_runTraceWithReturnPath": "Retornar ao mesmo caminho.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnread": "Vá para a mensagem mais antiga não lida",
|
||||
"chat_sendCooldown": "Por favor, aguarde um momento antes de reenviar.",
|
||||
"appSettings_languageHu": "Húngaro",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Ao abrir uma conversa com mensagens não lidas, role para a primeira mensagem não lida, em vez da mais recente.",
|
||||
"appSettings_languageJa": "Japonês",
|
||||
"appSettings_languageKo": "Coreano",
|
||||
"radioStats_tooltip": "Estatísticas de rádio e malha",
|
||||
"radioStats_screenTitle": "Estatísticas de rádio",
|
||||
"radioStats_notConnected": "Conecte-se a um dispositivo para visualizar estatísticas de rádio.",
|
||||
"radioStats_firmwareTooOld": "As estatísticas de rádio exigem o firmware v8 ou uma versão mais recente.",
|
||||
"radioStats_waiting": "Aguardando dados…",
|
||||
"radioStats_noiseFloor": "Nível de ruído: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Último RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Último SNR: {snr} dB",
|
||||
"radioStats_txAir": "Tempo de transmissão da TX (total): {seconds} s",
|
||||
"radioStats_rxAir": "Tempo de uso do RX (total): {seconds} s",
|
||||
"radioStats_chartCaption": "Nível de ruído (dBm) em amostras recentes.",
|
||||
"radioStats_stripNoise": "Nível de ruído: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Obtendo estatísticas de rádio…",
|
||||
"radioStats_settingsTile": "Estatísticas de rádio",
|
||||
"radioStats_settingsSubtitle": "Nível de ruído, RSSI, SNR e tempo de transmissão",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerTitle": "Traduza antes de enviar",
|
||||
"translation_enableSubtitle": "Traduzir mensagens recebidas e permitir a tradução antes do envio.",
|
||||
"translation_enableTitle": "Ativar a tradução",
|
||||
"translation_title": "Tradução",
|
||||
"translation_composerSubtitle": "Controla o estado padrão do ícone de tradução do compositor.",
|
||||
"translation_targetLanguage": "Língua-alvo",
|
||||
"translation_useAppLanguage": "Utilize o idioma da aplicação",
|
||||
"translation_downloadedModelLabel": "Modelo baixado",
|
||||
"translation_presetModelLabel": "Modelo pré-definido da Hugging Face",
|
||||
"translation_manualUrlLabel": "URL do modelo manual",
|
||||
"translation_downloading": "Baixando...",
|
||||
"translation_downloadModel": "Baixar modelo",
|
||||
"translation_working": "Trabalhando...",
|
||||
"translation_stop": "Pare",
|
||||
"translation_mergingChunks": "Combinando os fragmentos baixados em um único arquivo...",
|
||||
"translation_downloadedModels": "Modelos baixados",
|
||||
"translation_deleteModel": "Excluir modelo",
|
||||
"translation_modelDownloaded": "Modelo de tradução baixado.",
|
||||
"translation_downloadStopped": "Download interrompido.",
|
||||
"translation_downloadFailed": "Falha na descarga: {error}",
|
||||
"translation_enterUrlFirst": "Insira primeiro a URL do modelo.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_messageTranslation": "Tradução da mensagem",
|
||||
"translation_translateBeforeSending": "Traduzir antes de enviar",
|
||||
"translation_composerEnabledHint": "As mensagens serão traduzidas antes de serem enviadas.",
|
||||
"translation_composerDisabledHint": "Envie mensagens no idioma original, conforme digitado.",
|
||||
"translation_translateTo": "Traduzir para {language}",
|
||||
"translation_translationOptions": "Opções de tradução",
|
||||
"translation_systemLanguage": "Idioma do sistema",
|
||||
"scanner_linuxPairingShowPin": "Mostrar PIN",
|
||||
"scanner_linuxPairingHidePin": "Ocultar PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Insira o PIN para {deviceName} (deixe em branco se não houver).",
|
||||
"scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth",
|
||||
"repeater_cliQuickClockSync": "Sincronização do Relógio",
|
||||
"repeater_cliQuickDiscovery": "Descobrir Vizinhos"
|
||||
}
|
||||
+174
-2
@@ -1129,5 +1129,177 @@
|
||||
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
||||
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Показать контакты Discovery",
|
||||
"map_setAsMyLocation": "Установить мое местоположение"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Перекрытия ключа повтора",
|
||||
"map_runTraceWithReturnPath": "Вернуться обратно по тому же пути",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Пожалуйста, подождите немного, прежде чем отправлять сообщение снова.",
|
||||
"appSettings_jumpToOldestUnread": "Перейти к самому старому непрочитанному сообщению",
|
||||
"appSettings_languageHu": "Венгерский",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "При открытии чата с непрочитанными сообщениями, прокрутите страницу, чтобы увидеть первое непрочитанное сообщение, а не последнее.",
|
||||
"appSettings_languageJa": "Японский",
|
||||
"appSettings_languageKo": "Корейский",
|
||||
"radioStats_tooltip": "Статистика радио и беспроводной сети",
|
||||
"radioStats_screenTitle": "Статистика радиовещания",
|
||||
"radioStats_notConnected": "Подключитесь к устройству, чтобы просмотреть статистику радио.",
|
||||
"radioStats_firmwareTooOld": "Для работы радиостатистики требуется установленная версия прошивки v8 или более новая.",
|
||||
"radioStats_waiting": "Ожидаем данных…",
|
||||
"radioStats_noiseFloor": "Уровень шума: {noiseDbm} дБм",
|
||||
"radioStats_lastRssi": "Последнее значение RSSI: {rssiDbm} дБм",
|
||||
"radioStats_lastSnr": "Последнее значение SNR: {snr} дБ",
|
||||
"radioStats_txAir": "Время эфира на телеканале TX (общее): {seconds} секунд",
|
||||
"radioStats_rxAir": "Общее время использования RX (в секундах): {seconds} с",
|
||||
"radioStats_chartCaption": "Уровень шума (дБм) на основе последних измерений.",
|
||||
"radioStats_stripNoise": "Уровень шума: {noiseDbm} дБм",
|
||||
"radioStats_stripWaiting": "Получение данных о радио…",
|
||||
"radioStats_settingsTile": "Статистика радиовещания",
|
||||
"radioStats_settingsSubtitle": "Уровень шума, RSSI, SNR и время передачи",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_enableSubtitle": "Переводить входящие сообщения и позволять предварительный перевод перед отправкой.",
|
||||
"translation_composerTitle": "Переводить перед отправкой",
|
||||
"translation_title": "Перевод",
|
||||
"translation_enableTitle": "Включить перевод",
|
||||
"translation_composerSubtitle": "Управляет исходным состоянием значка перевода, предоставляемого редактором.",
|
||||
"translation_targetLanguage": "Целевой язык",
|
||||
"translation_useAppLanguage": "Используйте язык приложения",
|
||||
"translation_downloadedModelLabel": "Загруженная модель",
|
||||
"translation_presetModelLabel": "Предопределенная модель от Hugging Face",
|
||||
"translation_manualUrlLabel": "Ссылка на руководство",
|
||||
"translation_downloadModel": "Скачать модель",
|
||||
"translation_downloading": "Загрузка...",
|
||||
"translation_stop": "Прекратите",
|
||||
"translation_working": "Работа...",
|
||||
"translation_mergingChunks": "Объединение скачанных фрагментов в один финальный файл...",
|
||||
"translation_downloadedModels": "Загруженные модели",
|
||||
"translation_deleteModel": "Удалить модель",
|
||||
"translation_modelDownloaded": "Модель перевода загружена.",
|
||||
"translation_downloadStopped": "Процесс загрузки был прерван.",
|
||||
"translation_downloadFailed": "Не удалось скачать: {error}",
|
||||
"translation_enterUrlFirst": "Сначала введите URL модели.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_translateBeforeSending": "Перевести перед отправкой",
|
||||
"translation_composerEnabledHint": "Сообщения будут переведены перед отправкой.",
|
||||
"translation_messageTranslation": "Перевод сообщения",
|
||||
"translation_composerDisabledHint": "Отправляйте сообщения на языке, в котором они были изначально набраны.",
|
||||
"translation_translateTo": "Перевести на {language}",
|
||||
"translation_translationOptions": "Варианты перевода",
|
||||
"translation_systemLanguage": "Язык системы",
|
||||
"scanner_linuxPairingShowPin": "Показать PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Введите PIN‑код для {deviceName} (оставьте пустым, если нет).",
|
||||
"scanner_linuxPairingHidePin": "Скрыть PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth",
|
||||
"repeater_cliQuickDiscovery": "Обнаружить Соседей",
|
||||
"repeater_cliQuickClockSync": "Синхронизация часов"
|
||||
}
|
||||
+174
-2
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
||||
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
||||
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
|
||||
"map_setAsMyLocation": "Nastavte ako moju polohu"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Prekrývanie opakovača kľúča",
|
||||
"map_runTraceWithReturnPath": "Vráťte sa späť po tej istej ceste.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Prosím, počkajte chvíľu, než zašlete znova.",
|
||||
"appSettings_jumpToOldestUnread": "Presk oceň",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Pri otvorení chatu s neprečítanými správami, prejdite do prvého neprečítaného, namiesto poslednej.",
|
||||
"appSettings_languageHu": "Maďarský",
|
||||
"appSettings_languageJa": "Japonský",
|
||||
"appSettings_languageKo": "Kórejský",
|
||||
"radioStats_tooltip": "Statistiky rádiových a sieťových kanálov",
|
||||
"radioStats_screenTitle": "Štatistiky rádiových vysielaní",
|
||||
"radioStats_notConnected": "Pripojte sa k zariadeniu, aby ste mohli sledovať štatistiky rádiového vysielania.",
|
||||
"radioStats_firmwareTooOld": "Statistické údaje z rádia vyžadujú sprievodný softvér verzie v8 alebo novšej.",
|
||||
"radioStats_waiting": "Čakám na údaje…",
|
||||
"radioStats_noiseFloor": "Úroveň hluku: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Posledný údaj RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Posledná hodnota SNR: {snr} dB",
|
||||
"radioStats_txAir": "Čas vysielania na TX (celkový): {seconds} s",
|
||||
"radioStats_rxAir": "Čas RX (celkový): {seconds} s",
|
||||
"radioStats_chartCaption": "Úroveň šumu (dBm) pre posledné vzorky.",
|
||||
"radioStats_stripNoise": "Úroveň hluku: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Získavanie údajov o rádiu…",
|
||||
"radioStats_settingsTile": "Štatistiky rádiových vysielaní",
|
||||
"radioStats_settingsSubtitle": "Úroveň hluku, RSSI, SNR a časové rozloženie",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_enableSubtitle": "Prekladajte prichádzajúce správy a umožnite ich preklad pred odoslaním.",
|
||||
"translation_enableTitle": "Aktivovať preklad",
|
||||
"translation_composerTitle": "Preložte pred odeslaním",
|
||||
"translation_title": "Preklad",
|
||||
"translation_composerSubtitle": "Riadi výchoce stav ikony pre preklad, ktorú používa program.",
|
||||
"translation_targetLanguage": "Cieľový jazyk",
|
||||
"translation_useAppLanguage": "Použite jazyk aplikácie",
|
||||
"translation_downloadedModelLabel": "Stiahnutý model",
|
||||
"translation_presetModelLabel": "Prednastavený model od Hugging Face",
|
||||
"translation_manualUrlLabel": "Odkaz na manuál (v elektronickej forme)",
|
||||
"translation_downloadModel": "Stiahnuť model",
|
||||
"translation_downloading": "Stiahnutie...",
|
||||
"translation_working": "Práca...",
|
||||
"translation_stop": "Zastavte",
|
||||
"translation_mergingChunks": "Sliečenie stiahnutých častí do konečného súboru...",
|
||||
"translation_downloadedModels": "Stiahnuté modely",
|
||||
"translation_deleteModel": "Odstrániť model",
|
||||
"translation_modelDownloaded": "Model pre preklad bol stiahnutý.",
|
||||
"translation_downloadStopped": "Stiahnutie bolo prerušené.",
|
||||
"translation_downloadFailed": "Neúspešné stiahnutie: {error}",
|
||||
"translation_enterUrlFirst": "Najprv zadajte URL pre konkrétny model.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingHidePin": "Skryť PIN",
|
||||
"scanner_linuxPairingShowPin": "Zobraziť PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN pre párovanie cez Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Zadajte PIN pre {deviceName} (ak neexistuje, nechajte prázdne).",
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerDisabledHint": "Posielajte správy v pôvodnej písanom jazyku.",
|
||||
"translation_composerEnabledHint": "Správy budú preložené, než budú odoslané.",
|
||||
"translation_translateBeforeSending": "Preložte pred odeslaním",
|
||||
"translation_messageTranslation": "Preklad textu",
|
||||
"translation_translateTo": "Preložte do {language}",
|
||||
"translation_translationOptions": "Možnosti prekladania",
|
||||
"translation_systemLanguage": "Jazyk systému",
|
||||
"repeater_cliQuickClockSync": "Synchronizácia hodin",
|
||||
"repeater_cliQuickDiscovery": "Objaviť susedov"
|
||||
}
|
||||
+174
-2
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
||||
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
|
||||
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
|
||||
}
|
||||
"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",
|
||||
"map_showOverlaps": "Prekrivanje ključa ponovnega predvajanja",
|
||||
"map_runTraceWithReturnPath": "Vrni se nazaj po isti poti.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_languageHu": "Madžarski",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Ko odpirate klepet z neprebranimi sporočili, se premaknite na prvo neprebrano sporočilo, namesto najnovejšega.",
|
||||
"chat_sendCooldown": "Prosimo, počakajte trenutek, preden pošljete ponovno.",
|
||||
"appSettings_jumpToOldestUnread": "Pritisnite za najstarejše nepročitano sporočilo",
|
||||
"appSettings_languageJa": "Japonski",
|
||||
"appSettings_languageKo": "Korejski",
|
||||
"radioStats_tooltip": "Statistike za radio in mrežo",
|
||||
"radioStats_notConnected": "Povežite se z napravo, da si ogledate statistiko o radiju.",
|
||||
"radioStats_screenTitle": "Radijske statistike",
|
||||
"radioStats_firmwareTooOld": "Statistika za radio zahteva združljivo programsko opremo v8 ali kasnejše.",
|
||||
"radioStats_waiting": "Čakam na podatke…",
|
||||
"radioStats_noiseFloor": "Število šuma: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Najkasnejše vrednost RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Najkasnejše vrednost SNR: {snr} dB",
|
||||
"radioStats_txAir": "Čas na TX (skupno): {seconds} s",
|
||||
"radioStats_rxAir": "Čas, namenjen RX-ju (skupno): {seconds} s",
|
||||
"radioStats_chartCaption": "Ravnovredna raven šuma (dBm) za nedavne vzorce.",
|
||||
"radioStats_stripNoise": "Število šuma: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Prejemanje statistike o radiju…",
|
||||
"radioStats_settingsTile": "Radijske statistike",
|
||||
"radioStats_settingsSubtitle": "Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerTitle": "Preprištejte, preden pošljete",
|
||||
"translation_title": "Prevod",
|
||||
"translation_enableSubtitle": "Prevedite vstopne sporočila in omogočite predhodno prevajanje.",
|
||||
"translation_enableTitle": "Omogočite prevod",
|
||||
"translation_composerSubtitle": "Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.",
|
||||
"translation_targetLanguage": "Ciljna jezika",
|
||||
"translation_useAppLanguage": "Uporabite jezik aplikacije",
|
||||
"translation_downloadedModelLabel": "Naložen model",
|
||||
"translation_presetModelLabel": "Prednastavljeni model Hugging Face",
|
||||
"translation_manualUrlLabel": "URL za ročni model",
|
||||
"translation_downloadModel": "Prenesite model",
|
||||
"translation_downloading": "Izvajanje...",
|
||||
"translation_working": "Delo...",
|
||||
"translation_stop": "Prekliji",
|
||||
"translation_mergingChunks": "Sklapljanje prenesenih delov v končni datoteko...",
|
||||
"translation_downloadedModels": "Naloženi modeli",
|
||||
"translation_deleteModel": "Izbrisati model",
|
||||
"translation_modelDownloaded": "Model za prevajanje je bil naložen.",
|
||||
"translation_downloadStopped": "Prenos je bil prekinjen.",
|
||||
"translation_downloadFailed": "Izgovoritev ni bila uspešna: {error}",
|
||||
"translation_enterUrlFirst": "Najprej vnesite URL model.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_translateBeforeSending": "Preprištejte, preden pošljete",
|
||||
"translation_composerDisabledHint": "Pošljite sporočila v originalnem tipkanem jeziku.",
|
||||
"translation_composerEnabledHint": "Vsebina sporočil bo prevedena, preden jih pošljemo.",
|
||||
"translation_messageTranslation": "Prevod sporočila",
|
||||
"translation_translateTo": "Prevesti v {language}",
|
||||
"translation_translationOptions": "Možnosti prevoda",
|
||||
"translation_systemLanguage": "Jezik sistema",
|
||||
"scanner_linuxPairingShowPin": "Prikaži PIN",
|
||||
"scanner_linuxPairingHidePin": "Skrij PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Vnesite PIN za {deviceName} (pustite prazno, če ga ni).",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje",
|
||||
"repeater_cliQuickDiscovery": "Odkrijte sosede",
|
||||
"repeater_cliQuickClockSync": "Usklajevanje ure"
|
||||
}
|
||||
+174
-2
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
||||
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
||||
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
|
||||
"map_setAsMyLocation": "Ange som min plats"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Repeater-nyckelöverlappningar",
|
||||
"map_runTraceWithReturnPath": "Gå tillbaka på samma väg",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "När du öppnar en chatt med oinlästa meddelanden, scrolla till det första oinlästa meddelandet istället för det senaste.",
|
||||
"chat_sendCooldown": "Vänligen vänta en stund innan du skickar igen.",
|
||||
"appSettings_jumpToOldestUnread": "Gå direkt till det äldsta, obesvarade meddelandet",
|
||||
"appSettings_languageHu": "Ungerskt",
|
||||
"appSettings_languageJa": "Japanska",
|
||||
"appSettings_languageKo": "Koreanska",
|
||||
"radioStats_tooltip": "Radio- och mesh-statistik",
|
||||
"radioStats_screenTitle": "Radiostation",
|
||||
"radioStats_notConnected": "Anslut till en enhet för att visa radiostatistik.",
|
||||
"radioStats_firmwareTooOld": "Radio statistik kräver kompatibel firmware version 8 eller senare.",
|
||||
"radioStats_waiting": "Väntar på data…",
|
||||
"radioStats_noiseFloor": "Bakgrundsnivå: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Senaste RSSI-värde: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Senaste SNR: {snr} dB",
|
||||
"radioStats_txAir": "TX-tid (total): {seconds} sekunder",
|
||||
"radioStats_rxAir": "RX-tid (total): {seconds} s",
|
||||
"radioStats_chartCaption": "Ljudnivå (dBm) baserat på de senaste mätningarna.",
|
||||
"radioStats_stripNoise": "Bakgrundsnivå: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Hämtar radiostatistik…",
|
||||
"radioStats_settingsTile": "Radiostation",
|
||||
"radioStats_settingsSubtitle": "Bakgrundsnivå, RSSI, SNR och tillgänglig tid",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_enableSubtitle": "Översätt inkommande meddelanden och möjliggör översättning före avsändning.",
|
||||
"translation_enableTitle": "Aktivera översättning",
|
||||
"translation_title": "Översättning",
|
||||
"translation_composerTitle": "Översätt innan du skickar",
|
||||
"translation_composerSubtitle": "Styr standardtillståndet för kompositorns översättningsikon.",
|
||||
"translation_targetLanguage": "Målmedvetet språk",
|
||||
"translation_useAppLanguage": "Använd appens språk",
|
||||
"translation_downloadedModelLabel": "Nedladdad modell",
|
||||
"translation_presetModelLabel": "Fördefinierat Hugging Face-modell",
|
||||
"translation_manualUrlLabel": "Manualens URL",
|
||||
"translation_downloadModel": "Ladda ner modellen",
|
||||
"translation_downloading": "Nedladdning...",
|
||||
"translation_working": "Arbeta...",
|
||||
"translation_stop": "Stopp",
|
||||
"translation_mergingChunks": "Slå samman de nedladdade delarna till en slutlig fil...",
|
||||
"translation_downloadedModels": "Nedladdade modeller",
|
||||
"translation_deleteModel": "Ta bort modell",
|
||||
"translation_modelDownloaded": "Översättningsmodellen har laddats ner.",
|
||||
"translation_downloadStopped": "Nedladdningen avbruten.",
|
||||
"translation_downloadFailed": "Nedladdning misslyckades: {error}",
|
||||
"translation_enterUrlFirst": "Ange först en URL för en specifik modell.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerDisabledHint": "Skicka meddelanden på det ursprungliga, stavade språket.",
|
||||
"translation_translateBeforeSending": "Översätt innan du skickar",
|
||||
"translation_composerEnabledHint": "Meddelandena kommer att översättas innan de skickas.",
|
||||
"translation_messageTranslation": "Meddelandets översättning",
|
||||
"translation_translateTo": "Översätt till {language}",
|
||||
"translation_translationOptions": "Översättningsalternativ",
|
||||
"translation_systemLanguage": "Språk för systemet",
|
||||
"scanner_linuxPairingShowPin": "Visa PIN",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth‑parnings‑PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).",
|
||||
"scanner_linuxPairingHidePin": "Dölj PIN",
|
||||
"repeater_cliQuickDiscovery": "Upptäck grannar",
|
||||
"repeater_cliQuickClockSync": "Synkronisera klocka"
|
||||
}
|
||||
+174
-2
@@ -1889,5 +1889,177 @@
|
||||
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
||||
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Показати контакти Відкриття",
|
||||
"map_setAsMyLocation": "Встановити моє місцезнаходження"
|
||||
}
|
||||
"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}",
|
||||
"map_showOverlaps": "Перекриття ключа повторювача",
|
||||
"map_runTraceWithReturnPath": "Повернутися назад тим же шляхом",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Будь ласка, зачекайте трохи, перш ніж відправляти знову.",
|
||||
"appSettings_languageHu": "Угорський",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "При відкритті чату з не прочитаними повідомленнями, прокрутіть до першого не прочитаного повідомлення, а не до останнього.",
|
||||
"appSettings_jumpToOldestUnread": "Перейти до найстарішого непрочитаного повідомлення",
|
||||
"appSettings_languageJa": "Японська",
|
||||
"appSettings_languageKo": "Кореєська",
|
||||
"radioStats_tooltip": "Статистика радіо та мережі",
|
||||
"radioStats_screenTitle": "Дані про радіостанції",
|
||||
"radioStats_notConnected": "Підключіться до пристрою, щоб переглядати статистику радіопередач.",
|
||||
"radioStats_firmwareTooOld": "Статистика радіо приймача вимагає супутнього програмного забезпечення версії 8 або новішої.",
|
||||
"radioStats_waiting": "Очікую на отримання даних…",
|
||||
"radioStats_noiseFloor": "Рівень шуму: {noiseDbm} дБм",
|
||||
"radioStats_lastRssi": "Останній показник RSSI: {rssiDbm} дБм",
|
||||
"radioStats_lastSnr": "Останній показник SNR: {snr} дБ",
|
||||
"radioStats_txAir": "Час трансляції на телеканалі TX (загальний): {seconds} секунд",
|
||||
"radioStats_rxAir": "Загальний час використання RX: {seconds} секунд",
|
||||
"radioStats_chartCaption": "Рівень шуму (дБм) на основі останніх вимірювань.",
|
||||
"radioStats_stripNoise": "Рівень шуму: {noiseDbm} дБм",
|
||||
"radioStats_stripWaiting": "Отримано статистику радіо…",
|
||||
"radioStats_settingsTile": "Дані про радіостанції",
|
||||
"radioStats_settingsSubtitle": "Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerTitle": "Перекладіть перед відправкою",
|
||||
"translation_title": "Переклад",
|
||||
"translation_enableTitle": "Увімкнути переклад",
|
||||
"translation_enableSubtitle": "Перекладати отримані повідомлення та дозволяти попередній переклад перед відправкою.",
|
||||
"translation_composerSubtitle": "Контролює стан ікон перекладу, який використовується за замовчуванням.",
|
||||
"translation_targetLanguage": "Цільова мова",
|
||||
"translation_useAppLanguage": "Використовуйте мову додатку",
|
||||
"translation_downloadedModelLabel": "Завантажений шаблон",
|
||||
"translation_presetModelLabel": "Заздалегідь налаштований модель від Hugging Face",
|
||||
"translation_manualUrlLabel": "Посилання на веб-сторінку з інструкцією",
|
||||
"translation_downloadModel": "Завантажити модель",
|
||||
"translation_downloading": "Завантаження...",
|
||||
"translation_working": "Працюю...",
|
||||
"translation_stop": "Припинити",
|
||||
"translation_mergingChunks": "Об'єднання завантажених фрагментів у кінцевий файл...",
|
||||
"translation_downloadedModels": "Завантажені моделі",
|
||||
"translation_deleteModel": "Видалити модель",
|
||||
"translation_modelDownloaded": "Модель перекладу завантажена.",
|
||||
"translation_downloadStopped": "Завантаження призупинено.",
|
||||
"translation_downloadFailed": "Не вдалося завантажити: {error}",
|
||||
"translation_enterUrlFirst": "Спочатку введіть URL моделі.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerEnabledHint": "Повідомлення будуть перекладені перед відправленням.",
|
||||
"translation_messageTranslation": "Переклад повідомлення",
|
||||
"translation_composerDisabledHint": "Надсилайте повідомлення, використовуючи оригінальний текстовий формат.",
|
||||
"translation_translateBeforeSending": "Перекладіть перед відправкою",
|
||||
"translation_translateTo": "Перекласти на {language}",
|
||||
"translation_translationOptions": "Варіанти перекладу",
|
||||
"translation_systemLanguage": "Мова системи",
|
||||
"scanner_linuxPairingPinTitle": "PIN‑код спарювання Bluetooth",
|
||||
"scanner_linuxPairingShowPin": "Показати PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Введіть PIN для {deviceName} (залиште порожнім, якщо його немає).",
|
||||
"scanner_linuxPairingHidePin": "Приховати PIN",
|
||||
"repeater_cliQuickClockSync": "Синхронізація годинника",
|
||||
"repeater_cliQuickDiscovery": "Відкрити сусідів"
|
||||
}
|
||||
+174
-2
@@ -1894,5 +1894,177 @@
|
||||
"tcpErrorTimedOut": "TCP 连接超时。",
|
||||
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
||||
"map_showDiscoveryContacts": "显示发现联系人",
|
||||
"map_setAsMyLocation": "设置为我的位置"
|
||||
}
|
||||
"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": "遥测模式已更新",
|
||||
"map_showOverlaps": "重复键重叠",
|
||||
"map_runTraceWithReturnPath": "沿着相同的路径返回",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "请稍等片刻后再尝试发送。",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "在打开包含未读消息的聊天时,请滚动到第一个未读消息,而不是最新的消息。",
|
||||
"appSettings_jumpToOldestUnread": "跳转到最旧、未读的文章",
|
||||
"appSettings_languageHu": "匈牙利",
|
||||
"appSettings_languageJa": "日语",
|
||||
"appSettings_languageKo": "韩语",
|
||||
"radioStats_tooltip": "无线电和网状结构统计数据",
|
||||
"radioStats_screenTitle": "广播统计数据",
|
||||
"radioStats_notConnected": "连接到设备以查看收音机统计信息。",
|
||||
"radioStats_firmwareTooOld": "使用无线电统计功能需要配合使用 v8 或更高版本的固件。",
|
||||
"radioStats_waiting": "正在等待数据…",
|
||||
"radioStats_noiseFloor": "噪声水平:{noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "上次 RSSI 值:{rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "上次 SNR:{snr} dB",
|
||||
"radioStats_txAir": "TX 频道播出时间(总时长):{seconds} 秒",
|
||||
"radioStats_rxAir": "RX 使用时长(总时长):{seconds} 秒",
|
||||
"radioStats_chartCaption": "近期的噪声水平(dBm)。",
|
||||
"radioStats_stripNoise": "噪声水平:{noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "正在获取收音机数据…",
|
||||
"radioStats_settingsTile": "广播统计数据",
|
||||
"radioStats_settingsSubtitle": "噪声水平、RSSI、信噪比和空中时间",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_title": "翻译",
|
||||
"translation_enableSubtitle": "翻译收到的消息,并允许在发送前进行翻译。",
|
||||
"translation_composerTitle": "在发送之前进行翻译",
|
||||
"translation_enableTitle": "启用翻译功能",
|
||||
"translation_composerSubtitle": "控制作曲家翻译图标的默认状态。",
|
||||
"translation_targetLanguage": "目标语言",
|
||||
"translation_useAppLanguage": "使用应用程序语言",
|
||||
"translation_downloadedModelLabel": "下载的模型",
|
||||
"translation_presetModelLabel": "预设的 Hugging Face 模型",
|
||||
"translation_downloadModel": "下载模型",
|
||||
"translation_manualUrlLabel": "手动模型网址",
|
||||
"translation_downloading": "正在下载...",
|
||||
"translation_working": "工作中...",
|
||||
"translation_stop": "停止",
|
||||
"translation_mergingChunks": "将下载的片段合并成最终文件...",
|
||||
"translation_downloadedModels": "下载的模型",
|
||||
"translation_deleteModel": "删除模型",
|
||||
"translation_modelDownloaded": "翻译模型已下载。",
|
||||
"translation_downloadStopped": "下载已停止。",
|
||||
"translation_downloadFailed": "下载失败:{error}",
|
||||
"translation_enterUrlFirst": "首先,请输入模型的 URL。",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingPinTitle": "蓝牙配对 PIN",
|
||||
"scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN 码(如果为空,则留空)。",
|
||||
"scanner_linuxPairingHidePin": "隐藏 PIN",
|
||||
"scanner_linuxPairingShowPin": "显示PIN码",
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerDisabledHint": "使用原始的打字方式发送消息。",
|
||||
"translation_messageTranslation": "消息翻译",
|
||||
"translation_composerEnabledHint": "消息将在发送前进行翻译。",
|
||||
"translation_translateBeforeSending": "在发送前进行翻译",
|
||||
"translation_translateTo": "翻译成 {language}",
|
||||
"translation_translationOptions": "翻译选项",
|
||||
"translation_systemLanguage": "系统语言",
|
||||
"repeater_cliQuickDiscovery": "发现邻居",
|
||||
"repeater_cliQuickClockSync": "同步时钟"
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import 'services/app_debug_log_service.dart';
|
||||
import 'services/background_service.dart';
|
||||
import 'services/map_tile_cache_service.dart';
|
||||
import 'services/chat_text_scale_service.dart';
|
||||
import 'services/translation_service.dart';
|
||||
import 'services/ui_view_state_service.dart';
|
||||
import 'services/timeout_prediction_service.dart';
|
||||
import 'storage/prefs_manager.dart';
|
||||
@@ -41,6 +42,7 @@ void main() async {
|
||||
final backgroundService = BackgroundService();
|
||||
final mapTileCacheService = MapTileCacheService();
|
||||
final chatTextScaleService = ChatTextScaleService();
|
||||
final translationService = TranslationService(appSettingsService);
|
||||
final uiViewStateService = UiViewStateService();
|
||||
final timeoutPredictionService = TimeoutPredictionService(storage);
|
||||
|
||||
@@ -60,6 +62,7 @@ void main() async {
|
||||
_registerThirdPartyLicenses();
|
||||
|
||||
await chatTextScaleService.initialize();
|
||||
await translationService.refreshDownloadedModels();
|
||||
await uiViewStateService.initialize();
|
||||
await timeoutPredictionService.initialize();
|
||||
|
||||
@@ -68,6 +71,7 @@ void main() async {
|
||||
retryService: retryService,
|
||||
pathHistoryService: pathHistoryService,
|
||||
appSettingsService: appSettingsService,
|
||||
translationService: translationService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
backgroundService: backgroundService,
|
||||
@@ -93,6 +97,7 @@ void main() async {
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
chatTextScaleService: chatTextScaleService,
|
||||
translationService: translationService,
|
||||
uiViewStateService: uiViewStateService,
|
||||
timeoutPredictionService: timeoutPredictionService,
|
||||
),
|
||||
@@ -130,6 +135,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
final AppDebugLogService appDebugLogService;
|
||||
final MapTileCacheService mapTileCacheService;
|
||||
final ChatTextScaleService chatTextScaleService;
|
||||
final TranslationService translationService;
|
||||
final UiViewStateService uiViewStateService;
|
||||
final TimeoutPredictionService timeoutPredictionService;
|
||||
|
||||
@@ -144,6 +150,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
required this.appDebugLogService,
|
||||
required this.mapTileCacheService,
|
||||
required this.chatTextScaleService,
|
||||
required this.translationService,
|
||||
required this.uiViewStateService,
|
||||
required this.timeoutPredictionService,
|
||||
});
|
||||
@@ -159,6 +166,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||
ChangeNotifierProvider.value(value: appDebugLogService),
|
||||
ChangeNotifierProvider.value(value: chatTextScaleService),
|
||||
ChangeNotifierProvider.value(value: translationService),
|
||||
ChangeNotifierProvider.value(value: uiViewStateService),
|
||||
Provider.value(value: storage),
|
||||
Provider.value(value: mapTileCacheService),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'translation_support.dart';
|
||||
|
||||
enum UnitSystem { metric, imperial }
|
||||
|
||||
extension UnitSystemValue on UnitSystem {
|
||||
@@ -18,6 +20,7 @@ class AppSettings {
|
||||
final bool mapShowRepeaters;
|
||||
final bool mapShowChatNodes;
|
||||
final bool mapShowOtherNodes;
|
||||
final bool mapShowOverlaps;
|
||||
final double mapTimeFilterHours; // 0 = all time
|
||||
final bool mapKeyPrefixEnabled;
|
||||
final String mapKeyPrefix;
|
||||
@@ -32,6 +35,11 @@ class AppSettings {
|
||||
final bool notifyOnNewChannelMessage;
|
||||
final bool notifyOnNewAdvert;
|
||||
final bool autoRouteRotationEnabled;
|
||||
final double maxRouteWeight;
|
||||
final double initialRouteWeight;
|
||||
final double routeWeightSuccessIncrement;
|
||||
final double routeWeightFailureDecrement;
|
||||
final int maxMessageRetries;
|
||||
final String themeMode;
|
||||
final String? languageOverride; // null = system default
|
||||
final bool appDebugLogEnabled;
|
||||
@@ -42,12 +50,20 @@ class AppSettings {
|
||||
final bool mapShowDiscoveryContacts;
|
||||
final String tcpServerAddress;
|
||||
final int tcpServerPort;
|
||||
final bool jumpToOldestUnread;
|
||||
final bool translationEnabled;
|
||||
final String? translationTargetLanguageCode;
|
||||
final bool composerTranslationEnabled;
|
||||
final String? translationModelSourceUrl;
|
||||
final String? translationSelectedModelId;
|
||||
final List<TranslationModelRecord> translationDownloadedModels;
|
||||
|
||||
AppSettings({
|
||||
this.clearPathOnMaxRetry = false,
|
||||
this.mapShowRepeaters = true,
|
||||
this.mapShowChatNodes = true,
|
||||
this.mapShowOtherNodes = true,
|
||||
this.mapShowOverlaps = false,
|
||||
this.mapTimeFilterHours = 0, // Default to all time
|
||||
this.mapKeyPrefixEnabled = false,
|
||||
this.mapKeyPrefix = '',
|
||||
@@ -62,6 +78,11 @@ class AppSettings {
|
||||
this.notifyOnNewChannelMessage = true,
|
||||
this.notifyOnNewAdvert = true,
|
||||
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.languageOverride,
|
||||
this.appDebugLogEnabled = false,
|
||||
@@ -72,9 +93,17 @@ class AppSettings {
|
||||
this.mapShowDiscoveryContacts = true,
|
||||
this.tcpServerAddress = '',
|
||||
this.tcpServerPort = 0,
|
||||
this.jumpToOldestUnread = false,
|
||||
this.translationEnabled = false,
|
||||
this.translationTargetLanguageCode,
|
||||
this.composerTranslationEnabled = false,
|
||||
this.translationModelSourceUrl,
|
||||
this.translationSelectedModelId,
|
||||
List<TranslationModelRecord>? translationDownloadedModels,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
||||
mutedChannels = mutedChannels ?? {};
|
||||
mutedChannels = mutedChannels ?? {},
|
||||
translationDownloadedModels = translationDownloadedModels ?? const [];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
@@ -82,6 +111,7 @@ class AppSettings {
|
||||
'map_show_repeaters': mapShowRepeaters,
|
||||
'map_show_chat_nodes': mapShowChatNodes,
|
||||
'map_show_other_nodes': mapShowOtherNodes,
|
||||
'map_show_overlaps': mapShowOverlaps,
|
||||
'map_time_filter_hours': mapTimeFilterHours,
|
||||
'map_key_prefix_enabled': mapKeyPrefixEnabled,
|
||||
'map_key_prefix': mapKeyPrefix,
|
||||
@@ -96,6 +126,11 @@ class AppSettings {
|
||||
'notify_on_new_channel_message': notifyOnNewChannelMessage,
|
||||
'notify_on_new_advert': notifyOnNewAdvert,
|
||||
'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,
|
||||
'language_override': languageOverride,
|
||||
'app_debug_log_enabled': appDebugLogEnabled,
|
||||
@@ -106,6 +141,15 @@ class AppSettings {
|
||||
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
||||
'tcp_server_address': tcpServerAddress,
|
||||
'tcp_server_port': tcpServerPort,
|
||||
'jump_to_oldest_unread': jumpToOldestUnread,
|
||||
'translation_enabled': translationEnabled,
|
||||
'translation_target_language_code': translationTargetLanguageCode,
|
||||
'composer_translation_enabled': composerTranslationEnabled,
|
||||
'translation_model_source_url': translationModelSourceUrl,
|
||||
'translation_selected_model_id': translationSelectedModelId,
|
||||
'translation_downloaded_models': translationDownloadedModels
|
||||
.map((model) => model.toJson())
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +166,7 @@ class AppSettings {
|
||||
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
|
||||
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
|
||||
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
|
||||
mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false,
|
||||
mapTimeFilterHours:
|
||||
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
|
||||
@@ -142,6 +187,14 @@ class AppSettings {
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled:
|
||||
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',
|
||||
languageOverride: json['language_override'] as String?,
|
||||
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
|
||||
@@ -165,6 +218,25 @@ class AppSettings {
|
||||
json['map_show_discovery_contacts'] as bool? ?? true,
|
||||
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
|
||||
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
|
||||
jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false,
|
||||
translationEnabled: json['translation_enabled'] as bool? ?? false,
|
||||
translationTargetLanguageCode:
|
||||
json['translation_target_language_code'] as String?,
|
||||
composerTranslationEnabled:
|
||||
json['composer_translation_enabled'] as bool? ?? false,
|
||||
translationModelSourceUrl:
|
||||
json['translation_model_source_url'] as String?,
|
||||
translationSelectedModelId:
|
||||
json['translation_selected_model_id'] as String?,
|
||||
translationDownloadedModels:
|
||||
(json['translation_downloaded_models'] as List<dynamic>?)
|
||||
?.map(
|
||||
(entry) => TranslationModelRecord.fromJson(
|
||||
Map<String, dynamic>.from(entry as Map),
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,6 +245,7 @@ class AppSettings {
|
||||
bool? mapShowRepeaters,
|
||||
bool? mapShowChatNodes,
|
||||
bool? mapShowOtherNodes,
|
||||
bool? mapShowOverlaps,
|
||||
double? mapTimeFilterHours,
|
||||
bool? mapKeyPrefixEnabled,
|
||||
String? mapKeyPrefix,
|
||||
@@ -187,6 +260,11 @@ class AppSettings {
|
||||
bool? notifyOnNewChannelMessage,
|
||||
bool? notifyOnNewAdvert,
|
||||
bool? autoRouteRotationEnabled,
|
||||
double? maxRouteWeight,
|
||||
double? initialRouteWeight,
|
||||
double? routeWeightSuccessIncrement,
|
||||
double? routeWeightFailureDecrement,
|
||||
int? maxMessageRetries,
|
||||
String? themeMode,
|
||||
Object? languageOverride = _unset,
|
||||
bool? appDebugLogEnabled,
|
||||
@@ -197,12 +275,20 @@ class AppSettings {
|
||||
bool? mapShowDiscoveryContacts,
|
||||
String? tcpServerAddress,
|
||||
int? tcpServerPort,
|
||||
bool? jumpToOldestUnread,
|
||||
bool? translationEnabled,
|
||||
Object? translationTargetLanguageCode = _unset,
|
||||
bool? composerTranslationEnabled,
|
||||
Object? translationModelSourceUrl = _unset,
|
||||
Object? translationSelectedModelId = _unset,
|
||||
List<TranslationModelRecord>? translationDownloadedModels,
|
||||
}) {
|
||||
return AppSettings(
|
||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||
mapShowRepeaters: mapShowRepeaters ?? this.mapShowRepeaters,
|
||||
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
|
||||
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
|
||||
mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps,
|
||||
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
|
||||
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
|
||||
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
|
||||
@@ -222,6 +308,13 @@ class AppSettings {
|
||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||
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,
|
||||
languageOverride: languageOverride == _unset
|
||||
? this.languageOverride
|
||||
@@ -237,6 +330,21 @@ class AppSettings {
|
||||
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
||||
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
|
||||
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
|
||||
jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread,
|
||||
translationEnabled: translationEnabled ?? this.translationEnabled,
|
||||
translationTargetLanguageCode: translationTargetLanguageCode == _unset
|
||||
? this.translationTargetLanguageCode
|
||||
: translationTargetLanguageCode as String?,
|
||||
composerTranslationEnabled:
|
||||
composerTranslationEnabled ?? this.composerTranslationEnabled,
|
||||
translationModelSourceUrl: translationModelSourceUrl == _unset
|
||||
? this.translationModelSourceUrl
|
||||
: translationModelSourceUrl as String?,
|
||||
translationSelectedModelId: translationSelectedModelId == _unset
|
||||
? this.translationSelectedModelId
|
||||
: translationSelectedModelId as String?,
|
||||
translationDownloadedModels:
|
||||
translationDownloadedModels ?? this.translationDownloadedModels,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -24,20 +24,23 @@ class Channel {
|
||||
|
||||
bool get isPublicChannel => pskHex == publicChannelPsk;
|
||||
|
||||
static Channel? fromFrame(Uint8List data) {
|
||||
static Channel? fromFrame(Uint8List frame) {
|
||||
// CHANNEL_INFO format:
|
||||
// [0] = RESP_CODE_CHANNEL_INFO (18)
|
||||
// [1] = channel_idx
|
||||
// [2-33] = name (32 bytes, null-terminated)
|
||||
// [34-49] = psk (16 bytes)
|
||||
if (data.length < 50) return null;
|
||||
if (data[0] != respCodeChannelInfo) return null;
|
||||
|
||||
final index = data[1];
|
||||
final name = readCString(data, 2, 32);
|
||||
final psk = Uint8List.fromList(data.sublist(34, 50));
|
||||
|
||||
return Channel(index: index, name: name, psk: psk);
|
||||
if (frame.length < 50) return null;
|
||||
final reader = BufferReader(frame);
|
||||
try {
|
||||
if (reader.readByte() != respCodeChannelInfo) return null;
|
||||
final index = reader.readByte();
|
||||
final name = reader.readCStringGreedy(32);
|
||||
final psk = reader.readBytes(16);
|
||||
return Channel(index: index, name: name, psk: psk);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Channel empty(int index) {
|
||||
|
||||
+114
-79
@@ -2,6 +2,8 @@ import 'dart:typed_data';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/reaction_helper.dart';
|
||||
import '../helpers/smaz.dart';
|
||||
import 'translation_support.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
enum ChannelMessageStatus { pending, sent, failed }
|
||||
|
||||
@@ -23,9 +25,16 @@ class Repeat {
|
||||
}
|
||||
|
||||
class ChannelMessage {
|
||||
static const Object _unset = Object();
|
||||
|
||||
final Uint8List? senderKey;
|
||||
final String senderName;
|
||||
final String text;
|
||||
final String? originalText;
|
||||
final String? translatedText;
|
||||
final String? translatedLanguageCode;
|
||||
final MessageTranslationStatus translationStatus;
|
||||
final String? translationModelId;
|
||||
final DateTime timestamp;
|
||||
final bool isOutgoing;
|
||||
final ChannelMessageStatus status;
|
||||
@@ -36,6 +45,7 @@ class ChannelMessage {
|
||||
final List<Uint8List> pathVariants;
|
||||
final int? channelIndex;
|
||||
final String messageId;
|
||||
final String? packetHash;
|
||||
final String? replyToMessageId;
|
||||
final String? replyToSenderName;
|
||||
final String? replyToText;
|
||||
@@ -45,6 +55,11 @@ class ChannelMessage {
|
||||
this.senderKey,
|
||||
required this.senderName,
|
||||
required this.text,
|
||||
this.originalText,
|
||||
this.translatedText,
|
||||
this.translatedLanguageCode,
|
||||
this.translationStatus = MessageTranslationStatus.none,
|
||||
this.translationModelId,
|
||||
required this.timestamp,
|
||||
required this.isOutgoing,
|
||||
this.status = ChannelMessageStatus.pending,
|
||||
@@ -55,6 +70,7 @@ class ChannelMessage {
|
||||
List<Uint8List>? pathVariants,
|
||||
this.channelIndex,
|
||||
String? messageId,
|
||||
this.packetHash,
|
||||
this.replyToMessageId,
|
||||
this.replyToSenderName,
|
||||
this.replyToText,
|
||||
@@ -79,15 +95,34 @@ class ChannelMessage {
|
||||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
List<Uint8List>? pathVariants,
|
||||
String? packetHash,
|
||||
String? replyToMessageId,
|
||||
String? replyToSenderName,
|
||||
String? replyToText,
|
||||
Object? originalText = _unset,
|
||||
Object? translatedText = _unset,
|
||||
Object? translatedLanguageCode = _unset,
|
||||
MessageTranslationStatus? translationStatus,
|
||||
Object? translationModelId = _unset,
|
||||
Map<String, int>? reactions,
|
||||
}) {
|
||||
return ChannelMessage(
|
||||
senderKey: senderKey,
|
||||
senderName: senderName,
|
||||
text: text,
|
||||
originalText: originalText == _unset
|
||||
? this.originalText
|
||||
: originalText as String?,
|
||||
translatedText: translatedText == _unset
|
||||
? this.translatedText
|
||||
: translatedText as String?,
|
||||
translatedLanguageCode: translatedLanguageCode == _unset
|
||||
? this.translatedLanguageCode
|
||||
: translatedLanguageCode as String?,
|
||||
translationStatus: translationStatus ?? this.translationStatus,
|
||||
translationModelId: translationModelId == _unset
|
||||
? this.translationModelId
|
||||
: translationModelId as String?,
|
||||
timestamp: timestamp,
|
||||
isOutgoing: isOutgoing,
|
||||
status: status ?? this.status,
|
||||
@@ -98,6 +133,7 @@ class ChannelMessage {
|
||||
pathVariants: pathVariants ?? this.pathVariants,
|
||||
channelIndex: channelIndex,
|
||||
messageId: messageId,
|
||||
packetHash: packetHash ?? this.packetHash,
|
||||
replyToMessageId: replyToMessageId ?? this.replyToMessageId,
|
||||
replyToSenderName: replyToSenderName ?? this.replyToSenderName,
|
||||
replyToText: replyToText ?? this.replyToText,
|
||||
@@ -105,100 +141,99 @@ class ChannelMessage {
|
||||
);
|
||||
}
|
||||
|
||||
static ChannelMessage? fromFrame(Uint8List data) {
|
||||
static ChannelMessage? fromFrame(Uint8List frame) {
|
||||
// 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...]
|
||||
// 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];
|
||||
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
|
||||
int pathLen;
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
String text,
|
||||
String senderName,
|
||||
int channelIndex,
|
||||
) {
|
||||
int channelIndex, {
|
||||
String? originalText,
|
||||
String? translatedLanguageCode,
|
||||
String? translationModelId,
|
||||
}) {
|
||||
return ChannelMessage(
|
||||
senderKey: null,
|
||||
senderName: senderName,
|
||||
text: text,
|
||||
originalText: originalText,
|
||||
translatedLanguageCode: translatedLanguageCode,
|
||||
translationModelId: translationModelId,
|
||||
timestamp: DateTime.now(),
|
||||
isOutgoing: true,
|
||||
status: ChannelMessageStatus.pending,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Parsed `RESP_CODE_STATS` + `STATS_TYPE_RADIO` (14 bytes total).
|
||||
class CompanionRadioStats {
|
||||
final int noiseFloorDbm;
|
||||
final int lastRssiDbm;
|
||||
final double lastSnrDb;
|
||||
final int txAirSecs;
|
||||
final int rxAirSecs;
|
||||
final DateTime receivedAt;
|
||||
|
||||
const CompanionRadioStats({
|
||||
required this.noiseFloorDbm,
|
||||
required this.lastRssiDbm,
|
||||
required this.lastSnrDb,
|
||||
required this.txAirSecs,
|
||||
required this.rxAirSecs,
|
||||
required this.receivedAt,
|
||||
});
|
||||
|
||||
static CompanionRadioStats? tryParse(Uint8List frame) {
|
||||
if (frame.length < 14) return null;
|
||||
if (frame[0] != respCodeStats || frame[1] != statsTypeRadio) return null;
|
||||
try {
|
||||
final reader = BufferReader(frame);
|
||||
reader.skipBytes(2);
|
||||
final noise = reader.readInt16LE();
|
||||
final rssi = reader.readInt8();
|
||||
final snrRaw = reader.readInt8();
|
||||
final txAir = reader.readUInt32LE();
|
||||
final rxAir = reader.readUInt32LE();
|
||||
return CompanionRadioStats(
|
||||
noiseFloorDbm: noise,
|
||||
lastRssiDbm: rssi,
|
||||
lastSnrDb: snrRaw / 4.0,
|
||||
txAirSecs: txAir,
|
||||
rxAirSecs: rxAir,
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.warn('CompanionRadioStats parse error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-13
@@ -18,6 +18,7 @@ class Contact {
|
||||
final DateTime lastSeen;
|
||||
final DateTime lastMessageAt;
|
||||
final bool isActive;
|
||||
final bool wasPulled;
|
||||
final Uint8List? rawPacket;
|
||||
|
||||
Contact({
|
||||
@@ -34,6 +35,7 @@ class Contact {
|
||||
required this.lastSeen,
|
||||
DateTime? lastMessageAt,
|
||||
this.isActive = true,
|
||||
this.wasPulled = false,
|
||||
this.rawPacket,
|
||||
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
||||
|
||||
@@ -117,15 +119,14 @@ class Contact {
|
||||
);
|
||||
}
|
||||
|
||||
String get pathIdList {
|
||||
/// Formats path bytes into comma-separated hex groups of [hashByteWidth] bytes.
|
||||
String pathFormattedIdList(int hashByteWidth) {
|
||||
final pathBytes = pathBytesForDisplay;
|
||||
if (pathBytes.isEmpty) return '';
|
||||
final w = hashByteWidth.clamp(1, 8);
|
||||
final parts = <String>[];
|
||||
final groupSize = pathHashSize;
|
||||
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
||||
final end = (i + groupSize) <= pathBytes.length
|
||||
? (i + groupSize)
|
||||
: pathBytes.length;
|
||||
for (int i = 0; i < pathBytes.length; i += w) {
|
||||
final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length;
|
||||
final chunk = pathBytes.sublist(i, end);
|
||||
parts.add(
|
||||
chunk
|
||||
@@ -136,6 +137,9 @@ class Contact {
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/// Default grouping uses legacy single-byte hop hash width.
|
||||
String get pathIdList => pathFormattedIdList(pathHashSize);
|
||||
|
||||
String get shortPubKeyHex {
|
||||
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
||||
}
|
||||
@@ -157,6 +161,12 @@ class Contact {
|
||||
return null;
|
||||
}
|
||||
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 flags = reader.readByte();
|
||||
final pathLen = reader.readByte();
|
||||
@@ -166,15 +176,22 @@ class Contact {
|
||||
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||
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();
|
||||
|
||||
double? lat, lon;
|
||||
final latRaw = reader.readInt32LE();
|
||||
final lonRaw = reader.readInt32LE();
|
||||
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
if (reader.remaining >= 8) {
|
||||
final latRaw = reader.readInt32LE();
|
||||
final lonRaw = reader.readInt32LE();
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
}
|
||||
}
|
||||
|
||||
return Contact(
|
||||
@@ -182,7 +199,7 @@ class Contact {
|
||||
name: name.isEmpty ? 'Unknown' : name,
|
||||
type: type,
|
||||
flags: flags,
|
||||
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
|
||||
pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen,
|
||||
path: pathBytes,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
@@ -202,4 +219,7 @@ class Contact {
|
||||
|
||||
@override
|
||||
int get hashCode => publicKeyHex.hashCode;
|
||||
bool get teleBaseEnabled => (flags & contactFlagTeleBase) != 0;
|
||||
bool get teleLocEnabled => (flags & contactFlagTeleLoc) != 0;
|
||||
bool get teleEnvEnabled => (flags & contactFlagTeleEnv) != 0;
|
||||
}
|
||||
|
||||
+77
-30
@@ -1,28 +1,37 @@
|
||||
import 'dart:typed_data';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/reaction_helper.dart';
|
||||
import 'translation_support.dart';
|
||||
|
||||
enum MessageStatus { pending, sent, delivered, failed }
|
||||
|
||||
class Message {
|
||||
static const Object _unset = Object();
|
||||
|
||||
final Uint8List senderKey;
|
||||
final String text;
|
||||
final DateTime timestamp;
|
||||
final bool isOutgoing;
|
||||
final bool isCli;
|
||||
final MessageStatus status;
|
||||
final String? originalText;
|
||||
final String? translatedText;
|
||||
final String? translatedLanguageCode;
|
||||
final MessageTranslationStatus translationStatus;
|
||||
final String? translationModelId;
|
||||
|
||||
// NEW: Retry logic fields
|
||||
final String? messageId;
|
||||
final String messageId;
|
||||
final int retryCount;
|
||||
final int? estimatedTimeoutMs;
|
||||
final Uint8List? expectedAckHash;
|
||||
final int? expectedAckHash;
|
||||
final DateTime? sentAt;
|
||||
final DateTime? deliveredAt;
|
||||
final int? tripTimeMs;
|
||||
final int? pathLength;
|
||||
final Uint8List pathBytes;
|
||||
final Map<String, int> reactions;
|
||||
final Map<String, MessageStatus> reactionStatuses;
|
||||
final Uint8List fourByteRoomContactKey;
|
||||
|
||||
Message({
|
||||
@@ -32,7 +41,12 @@ class Message {
|
||||
required this.isOutgoing,
|
||||
this.isCli = false,
|
||||
this.status = MessageStatus.pending,
|
||||
this.messageId,
|
||||
String? messageId,
|
||||
this.originalText,
|
||||
this.translatedText,
|
||||
this.translatedLanguageCode,
|
||||
this.translationStatus = MessageTranslationStatus.none,
|
||||
this.translationModelId,
|
||||
this.retryCount = 0,
|
||||
this.estimatedTimeoutMs,
|
||||
this.expectedAckHash,
|
||||
@@ -43,9 +57,14 @@ class Message {
|
||||
Uint8List? pathBytes,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
Map<String, int>? reactions,
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
Map<String, MessageStatus>? reactionStatuses,
|
||||
}) : messageId =
|
||||
messageId ??
|
||||
'${timestamp.millisecondsSinceEpoch}_${pubKeyToHex(senderKey)}_${text.hashCode}',
|
||||
pathBytes = pathBytes ?? Uint8List(0),
|
||||
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
||||
reactions = reactions ?? {};
|
||||
reactions = reactions ?? {},
|
||||
reactionStatuses = reactionStatuses ?? {};
|
||||
|
||||
String get senderKeyHex => pubKeyToHex(senderKey);
|
||||
|
||||
@@ -53,14 +72,20 @@ class Message {
|
||||
MessageStatus? status,
|
||||
int? retryCount,
|
||||
int? estimatedTimeoutMs,
|
||||
Uint8List? expectedAckHash,
|
||||
int? expectedAckHash,
|
||||
DateTime? sentAt,
|
||||
DateTime? deliveredAt,
|
||||
int? tripTimeMs,
|
||||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
bool? isCli,
|
||||
Object? originalText = _unset,
|
||||
Object? translatedText = _unset,
|
||||
Object? translatedLanguageCode = _unset,
|
||||
MessageTranslationStatus? translationStatus,
|
||||
Object? translationModelId = _unset,
|
||||
Map<String, int>? reactions,
|
||||
Map<String, MessageStatus>? reactionStatuses,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
}) {
|
||||
return Message(
|
||||
@@ -71,6 +96,19 @@ class Message {
|
||||
isCli: isCli ?? this.isCli,
|
||||
status: status ?? this.status,
|
||||
messageId: messageId,
|
||||
originalText: originalText == _unset
|
||||
? this.originalText
|
||||
: originalText as String?,
|
||||
translatedText: translatedText == _unset
|
||||
? this.translatedText
|
||||
: translatedText as String?,
|
||||
translatedLanguageCode: translatedLanguageCode == _unset
|
||||
? this.translatedLanguageCode
|
||||
: translatedLanguageCode as String?,
|
||||
translationStatus: translationStatus ?? this.translationStatus,
|
||||
translationModelId: translationModelId == _unset
|
||||
? this.translationModelId
|
||||
: translationModelId as String?,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
estimatedTimeoutMs: estimatedTimeoutMs ?? this.estimatedTimeoutMs,
|
||||
expectedAckHash: expectedAckHash ?? this.expectedAckHash,
|
||||
@@ -80,49 +118,58 @@ class Message {
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
reactions: reactions ?? this.reactions,
|
||||
reactionStatuses: reactionStatuses ?? this.reactionStatuses,
|
||||
fourByteRoomContactKey:
|
||||
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||
);
|
||||
}
|
||||
|
||||
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
|
||||
if (data.length < msgTextOffset + 1) return null;
|
||||
static Message? fromFrame(Uint8List frame, Uint8List selfPubKey) {
|
||||
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];
|
||||
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||
final senderKey = reader.readBytes(pubKeySize);
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
Uint8List recipientKey,
|
||||
String text, {
|
||||
String? originalText,
|
||||
String? translatedLanguageCode,
|
||||
String? translationModelId,
|
||||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
}) {
|
||||
return Message(
|
||||
senderKey: recipientKey,
|
||||
text: text,
|
||||
originalText: originalText,
|
||||
translatedLanguageCode: translatedLanguageCode,
|
||||
translationModelId: translationModelId,
|
||||
timestamp: DateTime.now(),
|
||||
isOutgoing: true,
|
||||
isCli: false,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class PathRecord {
|
||||
final int hopCount;
|
||||
final int tripTimeMs;
|
||||
final DateTime timestamp;
|
||||
final DateTime? timestamp;
|
||||
final bool wasFloodDiscovery;
|
||||
final List<int> pathBytes;
|
||||
final int successCount;
|
||||
final int failureCount;
|
||||
final double routeWeight;
|
||||
|
||||
PathRecord({
|
||||
required this.hopCount,
|
||||
@@ -15,6 +16,7 @@ class PathRecord {
|
||||
required this.pathBytes,
|
||||
required this.successCount,
|
||||
required this.failureCount,
|
||||
this.routeWeight = 1.0,
|
||||
});
|
||||
|
||||
String get displayText =>
|
||||
@@ -24,11 +26,12 @@ class PathRecord {
|
||||
return {
|
||||
'hop_count': hopCount,
|
||||
'trip_time_ms': tripTimeMs,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'timestamp': timestamp?.toIso8601String(),
|
||||
'was_flood': wasFloodDiscovery,
|
||||
'path_bytes': pathBytes,
|
||||
'success_count': successCount,
|
||||
'failure_count': failureCount,
|
||||
'route_weight': routeWeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,12 +39,15 @@ class PathRecord {
|
||||
return PathRecord(
|
||||
hopCount: json['hop_count'] 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,
|
||||
pathBytes:
|
||||
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
successCount: json['success_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 {
|
||||
final List<int> pathBytes;
|
||||
final int hopCount;
|
||||
@@ -9,3 +15,38 @@ class PathSelection {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
enum MessageTranslationStatus { none, pending, completed, failed, skipped }
|
||||
|
||||
extension MessageTranslationStatusValue on MessageTranslationStatus {
|
||||
String get value {
|
||||
switch (this) {
|
||||
case MessageTranslationStatus.pending:
|
||||
return 'pending';
|
||||
case MessageTranslationStatus.completed:
|
||||
return 'completed';
|
||||
case MessageTranslationStatus.failed:
|
||||
return 'failed';
|
||||
case MessageTranslationStatus.skipped:
|
||||
return 'skipped';
|
||||
case MessageTranslationStatus.none:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageTranslationStatus parseMessageTranslationStatus(dynamic value) {
|
||||
if (value is! String) {
|
||||
return MessageTranslationStatus.none;
|
||||
}
|
||||
for (final status in MessageTranslationStatus.values) {
|
||||
if (status.value == value) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return MessageTranslationStatus.none;
|
||||
}
|
||||
|
||||
class TranslationModelRecord {
|
||||
final String id;
|
||||
final String name;
|
||||
final String sourceUrl;
|
||||
final String localPath;
|
||||
final DateTime downloadedAt;
|
||||
final int fileSizeBytes;
|
||||
|
||||
const TranslationModelRecord({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.sourceUrl,
|
||||
required this.localPath,
|
||||
required this.downloadedAt,
|
||||
required this.fileSizeBytes,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'source_url': sourceUrl,
|
||||
'local_path': localPath,
|
||||
'downloaded_at': downloadedAt.millisecondsSinceEpoch,
|
||||
'file_size_bytes': fileSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
factory TranslationModelRecord.fromJson(Map<String, dynamic> json) {
|
||||
return TranslationModelRecord(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
sourceUrl: json['source_url'] as String? ?? '',
|
||||
localPath: json['local_path'] as String? ?? '',
|
||||
downloadedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['downloaded_at'] as int? ?? 0,
|
||||
),
|
||||
fileSizeBytes: json['file_size_bytes'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String translationModelFriendlyName(TranslationModelRecord model) {
|
||||
switch (model.id) {
|
||||
case 'hy-mt1.5-1.8b-q4_k_m':
|
||||
return 'Tencent HY-MT 1.5 1.8B Q4_K_M';
|
||||
case 'hy-mt1.5-1.8b-q6_k':
|
||||
return 'Tencent HY-MT 1.5 1.8B Q6_K';
|
||||
default:
|
||||
final trimmed = model.name.trim();
|
||||
if (trimmed.endsWith('.gguf')) {
|
||||
return trimmed.substring(0, trimmed.length - 5);
|
||||
}
|
||||
return trimmed.isEmpty ? model.id : trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
class TranslationLanguageOption {
|
||||
final String code;
|
||||
final String label;
|
||||
|
||||
const TranslationLanguageOption({required this.code, required this.label});
|
||||
}
|
||||
|
||||
const List<TranslationLanguageOption> supportedTranslationLanguages = [
|
||||
TranslationLanguageOption(code: 'bg', label: 'Bulgarian'),
|
||||
TranslationLanguageOption(code: 'de', label: 'German'),
|
||||
TranslationLanguageOption(code: 'en', label: 'English'),
|
||||
TranslationLanguageOption(code: 'es', label: 'Spanish'),
|
||||
TranslationLanguageOption(code: 'fr', label: 'French'),
|
||||
TranslationLanguageOption(code: 'hu', label: 'Hungarian'),
|
||||
TranslationLanguageOption(code: 'it', label: 'Italian'),
|
||||
TranslationLanguageOption(code: 'ja', label: 'Japanese'),
|
||||
TranslationLanguageOption(code: 'ko', label: 'Korean'),
|
||||
TranslationLanguageOption(code: 'nl', label: 'Dutch'),
|
||||
TranslationLanguageOption(code: 'pl', label: 'Polish'),
|
||||
TranslationLanguageOption(code: 'pt', label: 'Portuguese'),
|
||||
TranslationLanguageOption(code: 'ru', label: 'Russian'),
|
||||
TranslationLanguageOption(code: 'sk', label: 'Slovak'),
|
||||
TranslationLanguageOption(code: 'sl', label: 'Slovenian'),
|
||||
TranslationLanguageOption(code: 'sv', label: 'Swedish'),
|
||||
TranslationLanguageOption(code: 'uk', label: 'Ukrainian'),
|
||||
TranslationLanguageOption(code: 'zh', label: 'Chinese'),
|
||||
];
|
||||
|
||||
final List<TranslationModelRecord> translationPresetModels = [
|
||||
TranslationModelRecord(
|
||||
id: 'hy-mt1.5-1.8b-q4_k_m',
|
||||
name: 'HY-MT1.5-1.8B-Q4_K_M.gguf',
|
||||
sourceUrl:
|
||||
'https://huggingface.co/tencent/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B-Q4_K_M.gguf?download=true',
|
||||
localPath: '',
|
||||
downloadedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
fileSizeBytes: 0,
|
||||
),
|
||||
TranslationModelRecord(
|
||||
id: 'hy-mt1.5-1.8b-q6_k',
|
||||
name: 'HY-MT1.5-1.8B-Q6_K.gguf',
|
||||
sourceUrl:
|
||||
'https://huggingface.co/tencent/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B-Q6_K.gguf?download=true',
|
||||
localPath: '',
|
||||
downloadedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
fileSizeBytes: 0,
|
||||
),
|
||||
];
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/translation_support.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/translation_service.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import 'map_cache_screen.dart';
|
||||
|
||||
@@ -21,26 +24,46 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Consumer2<AppSettingsService, MeshCoreConnector>(
|
||||
builder: (context, settingsService, connector, child) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildAppearanceCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildNotificationsCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildMessagingCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildBatteryCard(context, settingsService, connector),
|
||||
const SizedBox(height: 16),
|
||||
_buildMapSettingsCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildDebugCard(context, settingsService),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child:
|
||||
Consumer3<
|
||||
AppSettingsService,
|
||||
MeshCoreConnector,
|
||||
TranslationService
|
||||
>(
|
||||
builder:
|
||||
(
|
||||
context,
|
||||
settingsService,
|
||||
connector,
|
||||
translationService,
|
||||
child,
|
||||
) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildAppearanceCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildNotificationsCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildMessagingCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
if (!kIsWeb) ...[
|
||||
_buildTranslationCard(
|
||||
context,
|
||||
settingsService,
|
||||
translationService,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_buildBatteryCard(context, settingsService, connector),
|
||||
const SizedBox(height: 16),
|
||||
_buildMapSettingsCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildDebugCard(context, settingsService),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -291,6 +314,14 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.vertical_align_top),
|
||||
title: Text(context.l10n.appSettings_jumpToOldestUnread),
|
||||
subtitle: Text(context.l10n.appSettings_jumpToOldestUnreadSubtitle),
|
||||
value: settingsService.settings.jumpToOldestUnread,
|
||||
onChanged: settingsService.setJumpToOldestUnread,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.alt_route),
|
||||
title: Text(context.l10n.appSettings_autoRouteRotation),
|
||||
@@ -310,6 +341,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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -410,6 +553,211 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTranslationCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
TranslationService translationService,
|
||||
) {
|
||||
final settings = settingsService.settings;
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
context.l10n.translation_title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.translate),
|
||||
title: Text(context.l10n.translation_enableTitle),
|
||||
subtitle: Text(context.l10n.translation_enableSubtitle),
|
||||
value: settings.translationEnabled,
|
||||
onChanged: settingsService.setTranslationEnabled,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.outgoing_mail),
|
||||
title: Text(context.l10n.translation_composerTitle),
|
||||
subtitle: Text(context.l10n.translation_composerSubtitle),
|
||||
value: settings.composerTranslationEnabled,
|
||||
onChanged: settingsService.setComposerTranslationEnabled,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: Text(context.l10n.translation_targetLanguage),
|
||||
subtitle: Text(
|
||||
_translationLanguageLabel(
|
||||
context,
|
||||
settings.translationTargetLanguageCode,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () =>
|
||||
_showTranslationLanguageDialog(context, settingsService),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: settings.translationSelectedModelId,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.translation_downloadedModelLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final model in settings.translationDownloadedModels)
|
||||
DropdownMenuItem(
|
||||
value: model.id,
|
||||
child: Text(translationModelFriendlyName(model)),
|
||||
),
|
||||
],
|
||||
onChanged: settings.translationDownloadedModels.isEmpty
|
||||
? null
|
||||
: (value) {
|
||||
settingsService.setTranslationSelectedModelId(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: null,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.translation_presetModelLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final preset in translationPresetModels)
|
||||
DropdownMenuItem(
|
||||
value: preset.sourceUrl,
|
||||
child: Text(translationModelFriendlyName(preset)),
|
||||
),
|
||||
],
|
||||
onChanged: translationService.isBusy
|
||||
? null
|
||||
: (value) async {
|
||||
if (value == null) return;
|
||||
final preset = translationPresetModels.firstWhere(
|
||||
(entry) => entry.sourceUrl == value,
|
||||
);
|
||||
await _downloadTranslationModel(
|
||||
context,
|
||||
translationService,
|
||||
settingsService,
|
||||
sourceUrl: preset.sourceUrl,
|
||||
fileName: preset.name,
|
||||
id: preset.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
_TranslationUrlField(
|
||||
initialValue: settings.translationModelSourceUrl ?? '',
|
||||
onChanged: settingsService.setTranslationModelSourceUrl,
|
||||
onDownload: translationService.isBusy
|
||||
? null
|
||||
: (url) => _downloadTranslationModel(
|
||||
context,
|
||||
translationService,
|
||||
settingsService,
|
||||
sourceUrl: url,
|
||||
),
|
||||
downloadLabel: translationService.isDownloading
|
||||
? context.l10n.translation_downloading
|
||||
: translationService.isBusy
|
||||
? context.l10n.translation_working
|
||||
: context.l10n.translation_downloadModel,
|
||||
isDownloading: translationService.isDownloading,
|
||||
onCancel: translationService.cancelDownload,
|
||||
labelText: context.l10n.translation_manualUrlLabel,
|
||||
stopLabel: context.l10n.translation_stop,
|
||||
),
|
||||
if (translationService.isDownloading) ...[
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value:
|
||||
translationService.downloadFileName ==
|
||||
'Merging chunks...'
|
||||
? null
|
||||
: translationService.downloadProgress,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_downloadProgressLabel(context, translationService),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (settings.translationDownloadedModels.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
context.l10n.translation_downloadedModels,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final model in settings.translationDownloadedModels)
|
||||
Card.outlined(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: Icon(
|
||||
model.id == settings.translationSelectedModelId
|
||||
? Icons.check_circle
|
||||
: Icons.memory_outlined,
|
||||
),
|
||||
title: Text(translationModelFriendlyName(model)),
|
||||
subtitle: Text(_downloadedModelLabel(model)),
|
||||
trailing: IconButton(
|
||||
tooltip: context.l10n.translation_deleteModel,
|
||||
onPressed: translationService.isBusy
|
||||
? null
|
||||
: () => _deleteTranslationModel(
|
||||
context,
|
||||
translationService,
|
||||
model,
|
||||
),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
onTap: () => settingsService
|
||||
.setTranslationSelectedModelId(model.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (translationService.lastError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
translationService.lastError!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed rendering issues
|
||||
Widget _buildBatteryCard(
|
||||
BuildContext context,
|
||||
@@ -577,6 +925,12 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
return context.l10n.appSettings_languageRu;
|
||||
case 'uk':
|
||||
return context.l10n.appSettings_languageUk;
|
||||
case 'hu':
|
||||
return context.l10n.appSettings_languageHu;
|
||||
case 'ja':
|
||||
return context.l10n.appSettings_languageJa;
|
||||
case 'ko':
|
||||
return context.l10n.appSettings_languageKo;
|
||||
default:
|
||||
return context.l10n.appSettings_languageSystem;
|
||||
}
|
||||
@@ -664,6 +1018,18 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
title: Text(context.l10n.appSettings_languageUk),
|
||||
value: 'uk',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageHu),
|
||||
value: 'hu',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageJa),
|
||||
value: 'ja',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageKo),
|
||||
value: 'ko',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -772,6 +1138,124 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTranslationLanguageDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _TranslationLanguageDialogContent(
|
||||
currentLanguageCode:
|
||||
settingsService.settings.translationTargetLanguageCode,
|
||||
onLanguageSelected: (value) {
|
||||
settingsService.setTranslationTargetLanguageCode(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _downloadTranslationModel(
|
||||
BuildContext context,
|
||||
TranslationService translationService,
|
||||
AppSettingsService settingsService, {
|
||||
required String sourceUrl,
|
||||
String? fileName,
|
||||
String? id,
|
||||
}) async {
|
||||
if (sourceUrl.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.translation_enterUrlFirst)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await translationService.downloadModel(
|
||||
sourceUrl: sourceUrl,
|
||||
fileName: fileName,
|
||||
id: id,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.translation_modelDownloaded)),
|
||||
);
|
||||
await settingsService.setTranslationEnabled(true);
|
||||
} on TranslationDownloadCancelled {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.translation_downloadStopped)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.translation_downloadFailed(error.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _translationLanguageLabel(BuildContext context, String? languageCode) {
|
||||
if (languageCode == null || languageCode.isEmpty) {
|
||||
return context.l10n.translation_useAppLanguage;
|
||||
}
|
||||
for (final option in supportedTranslationLanguages) {
|
||||
if (option.code == languageCode) {
|
||||
return option.label;
|
||||
}
|
||||
}
|
||||
return languageCode.toUpperCase();
|
||||
}
|
||||
|
||||
String _downloadProgressLabel(
|
||||
BuildContext context,
|
||||
TranslationService translationService,
|
||||
) {
|
||||
final fileName = translationService.downloadFileName ?? 'Model';
|
||||
if (fileName == 'Merging chunks...') {
|
||||
return context.l10n.translation_mergingChunks;
|
||||
}
|
||||
final currentMb = translationService.downloadedBytes / (1024 * 1024);
|
||||
final totalBytes = translationService.downloadTotalBytes;
|
||||
if (totalBytes == null || totalBytes <= 0) {
|
||||
return '$fileName: ${currentMb.toStringAsFixed(1)} MB';
|
||||
}
|
||||
final totalMb = totalBytes / (1024 * 1024);
|
||||
final percent = ((translationService.downloadProgress ?? 0) * 100)
|
||||
.toStringAsFixed(0);
|
||||
return '$fileName: ${currentMb.toStringAsFixed(1)} / ${totalMb.toStringAsFixed(1)} MB ($percent%)';
|
||||
}
|
||||
|
||||
Future<void> _deleteTranslationModel(
|
||||
BuildContext context,
|
||||
TranslationService translationService,
|
||||
TranslationModelRecord model,
|
||||
) async {
|
||||
try {
|
||||
await translationService.removeModel(model);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
// TODO: l10n
|
||||
content: Text('Deleted ${translationModelFriendlyName(model)}.'),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Delete failed: $error')),
|
||||
); // TODO: l10n
|
||||
}
|
||||
}
|
||||
|
||||
String _downloadedModelLabel(TranslationModelRecord model) {
|
||||
final sizeMb = model.fileSizeBytes / (1024 * 1024);
|
||||
final source = model.sourceUrl.isEmpty ? model.name : model.sourceUrl;
|
||||
return '${sizeMb.toStringAsFixed(1)} MB • $source';
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
@@ -812,3 +1296,179 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the [TextEditingController] for the manual model URL field so it
|
||||
/// survives rebuilds of the parent [Consumer3].
|
||||
class _TranslationUrlField extends StatefulWidget {
|
||||
const _TranslationUrlField({
|
||||
required this.initialValue,
|
||||
required this.onChanged,
|
||||
required this.onDownload,
|
||||
required this.downloadLabel,
|
||||
required this.isDownloading,
|
||||
required this.onCancel,
|
||||
required this.labelText,
|
||||
required this.stopLabel,
|
||||
});
|
||||
|
||||
final String initialValue;
|
||||
final ValueChanged<String> onChanged;
|
||||
final void Function(String url)? onDownload;
|
||||
final String downloadLabel;
|
||||
final bool isDownloading;
|
||||
final VoidCallback onCancel;
|
||||
final String labelText;
|
||||
final String stopLabel;
|
||||
|
||||
@override
|
||||
State<_TranslationUrlField> createState() => _TranslationUrlFieldState();
|
||||
}
|
||||
|
||||
class _TranslationUrlFieldState extends State<_TranslationUrlField> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: widget.onChanged,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: widget.onDownload == null
|
||||
? null
|
||||
: () => widget.onDownload!(_controller.text.trim()),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text(widget.downloadLabel),
|
||||
),
|
||||
),
|
||||
if (widget.isDownloading) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: widget.onCancel,
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
label: Text(widget.stopLabel),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog content for choosing the translation target language.
|
||||
/// Owns the search [TextEditingController] so it is properly disposed.
|
||||
class _TranslationLanguageDialogContent extends StatefulWidget {
|
||||
const _TranslationLanguageDialogContent({
|
||||
required this.currentLanguageCode,
|
||||
required this.onLanguageSelected,
|
||||
});
|
||||
|
||||
final String? currentLanguageCode;
|
||||
final ValueChanged<String?> onLanguageSelected;
|
||||
|
||||
@override
|
||||
State<_TranslationLanguageDialogContent> createState() =>
|
||||
_TranslationLanguageDialogContentState();
|
||||
}
|
||||
|
||||
class _TranslationLanguageDialogContentState
|
||||
extends State<_TranslationLanguageDialogContent> {
|
||||
late final TextEditingController _searchController;
|
||||
List<TranslationLanguageOption> _filtered = supportedTranslationLanguages;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.translation_targetLanguage),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
setState(() {
|
||||
_filtered = supportedTranslationLanguages.where((option) {
|
||||
return option.label.toLowerCase().contains(normalized) ||
|
||||
option.code.toLowerCase().contains(normalized);
|
||||
}).toList();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Flexible(
|
||||
child: RadioGroup<String?>(
|
||||
groupValue: widget.currentLanguageCode,
|
||||
onChanged: (value) {
|
||||
widget.onLanguageSelected(value);
|
||||
},
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
RadioListTile<String?>(
|
||||
value: null,
|
||||
title: Text(context.l10n.translation_useAppLanguage),
|
||||
),
|
||||
for (final option in _filtered)
|
||||
RadioListTile<String?>(
|
||||
value: option.code,
|
||||
title: Text(option.label),
|
||||
subtitle: Text(option.code.toUpperCase()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,66 +283,66 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
if (payload.length < 101) {
|
||||
return 'ADVERT (short)';
|
||||
}
|
||||
var offset = 0;
|
||||
final pubKey = _bytesToHex(
|
||||
payload.sublist(offset, offset + 32),
|
||||
spaced: false,
|
||||
);
|
||||
offset += 32;
|
||||
final timestamp = readUint32LE(payload, offset);
|
||||
offset += 4;
|
||||
offset += 64; // signature
|
||||
final flags = payload[offset++];
|
||||
final role = _deviceRoleLabel(flags & 0x0F);
|
||||
final hasLocation = (flags & 0x10) != 0;
|
||||
final hasFeature1 = (flags & 0x20) != 0;
|
||||
final hasFeature2 = (flags & 0x40) != 0;
|
||||
final hasName = (flags & 0x80) != 0;
|
||||
String? name;
|
||||
double? lat;
|
||||
double? lon;
|
||||
if (hasLocation && payload.length >= offset + 8) {
|
||||
lat = readInt32LE(payload, offset) / 1000000.0;
|
||||
lon = readInt32LE(payload, offset + 4) / 1000000.0;
|
||||
offset += 8;
|
||||
final reader = BufferReader(payload);
|
||||
try {
|
||||
final pubKey = _bytesToHex(reader.readBytes(pubKeySize), spaced: false);
|
||||
|
||||
final timestamp = reader.readUInt32LE();
|
||||
reader.skipBytes(signatureSize);
|
||||
final flags = reader.readByte();
|
||||
final role = _deviceRoleLabel(flags & 0x0F);
|
||||
final hasLocation = (flags & 0x10) != 0;
|
||||
final hasFeature1 = (flags & 0x20) != 0;
|
||||
final hasFeature2 = (flags & 0x40) != 0;
|
||||
final hasName = (flags & 0x80) != 0;
|
||||
String? name;
|
||||
double? lat;
|
||||
double? lon;
|
||||
if (hasLocation) {
|
||||
lat = reader.readInt32LE() / 1000000.0;
|
||||
lon = reader.readInt32LE() / 1000000.0;
|
||||
}
|
||||
if (hasFeature1) reader.skipBytes(2);
|
||||
if (hasFeature2) reader.skipBytes(2);
|
||||
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) {
|
||||
if (payload.isEmpty) return 'CONTROL (empty)';
|
||||
final flags = payload[0];
|
||||
final subType = flags & 0xF0;
|
||||
if (subType == 0x80) {
|
||||
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
||||
final typeFilter = payload[1];
|
||||
final tag = readUint32LE(payload, 2);
|
||||
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
|
||||
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
|
||||
final reader = BufferReader(payload);
|
||||
try {
|
||||
final flags = reader.readByte();
|
||||
final subType = flags & 0xF0;
|
||||
if (subType == 0x80) {
|
||||
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
||||
final typeFilter = reader.readByte();
|
||||
final tag = reader.readInt32LE();
|
||||
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) {
|
||||
|
||||
@@ -4,28 +4,32 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../helpers/chat_scroll_controller.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/link_handler.dart';
|
||||
import '../helpers/reaction_helper.dart';
|
||||
import '../helpers/utf8_length_limiter.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/translation_support.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/chat_text_scale_service.dart';
|
||||
import '../services/translation_service.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../widgets/chat_zoom_wrapper.dart';
|
||||
import '../widgets/emoji_picker.dart';
|
||||
import '../widgets/gif_message.dart';
|
||||
import '../widgets/jump_to_bottom_button.dart';
|
||||
import '../widgets/gif_picker.dart';
|
||||
import '../widgets/message_translation_button.dart';
|
||||
import '../widgets/message_status_icon.dart';
|
||||
import '../widgets/radio_stats_entry.dart';
|
||||
import '../widgets/translated_message_content.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
|
||||
@@ -47,6 +51,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
bool _isLoadingOlder = false;
|
||||
|
||||
MeshCoreConnector? _connector;
|
||||
DateTime? _lastChannelSendAt;
|
||||
bool _channelSkipNextBottomSnap = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,11 +61,45 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_connector = context.read<MeshCoreConnector>();
|
||||
_connector?.setActiveChannel(widget.channel.index);
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final idx = widget.channel.index;
|
||||
final unread = connector.getUnreadCountForChannelIndex(idx);
|
||||
ChannelMessage? anchor;
|
||||
if (settings.jumpToOldestUnread && unread > 0) {
|
||||
anchor = _findOldestUnreadChannelAnchor(
|
||||
connector.getChannelMessages(widget.channel),
|
||||
unread,
|
||||
);
|
||||
}
|
||||
connector.setActiveChannel(idx);
|
||||
_connector = connector;
|
||||
if (anchor != null) {
|
||||
_channelSkipNextBottomSnap = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_scrollToMessage(anchor!.messageId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ChannelMessage? _findOldestUnreadChannelAnchor(
|
||||
List<ChannelMessage> messages,
|
||||
int unreadCount,
|
||||
) {
|
||||
if (unreadCount <= 0 || messages.isEmpty) return null;
|
||||
var n = 0;
|
||||
ChannelMessage? oldest;
|
||||
for (final m in messages.reversed) {
|
||||
if (m.isOutgoing) continue;
|
||||
n++;
|
||||
oldest = m;
|
||||
if (n >= unreadCount) break;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
void _onTextFieldFocusChange() {
|
||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||
_scrollController.handleKeyboardOpen();
|
||||
@@ -166,6 +206,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
const RadioStatsIconButton(),
|
||||
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(
|
||||
top: false,
|
||||
@@ -216,6 +284,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_channelSkipNextBottomSnap) {
|
||||
_channelSkipNextBottomSnap = false;
|
||||
return;
|
||||
}
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
@@ -285,6 +357,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final isOutgoing = message.isOutgoing;
|
||||
final gifId = _parseGifId(message.text);
|
||||
final poi = _parsePoiMessage(message.text);
|
||||
final translatedDisplayText =
|
||||
message.translatedText != null &&
|
||||
message.translatedText!.trim().isNotEmpty
|
||||
? message.translatedText!.trim()
|
||||
: message.text;
|
||||
final originalDisplayText = message.isOutgoing
|
||||
? message.originalText
|
||||
: (translatedDisplayText != message.text ? message.text : null);
|
||||
final displayPath = message.pathBytes.isNotEmpty
|
||||
? message.pathBytes
|
||||
: (message.pathVariants.isNotEmpty
|
||||
@@ -311,8 +391,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
],
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: () => _showMessagePathInfo(message),
|
||||
onTap: PlatformInfo.isDesktop
|
||||
? null
|
||||
: () => _showMessagePathInfo(message),
|
||||
onLongPress: () => _showMessageActions(message),
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||
? (_) => _showMessageActions(message)
|
||||
: null,
|
||||
child: Container(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
@@ -430,24 +515,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Linkify(
|
||||
text: message.text,
|
||||
child: TranslatedMessageContent(
|
||||
displayText: translatedDisplayText,
|
||||
originalText: originalDisplayText,
|
||||
style: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
originalStyle: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) => LinkHandler.handleLinkTap(
|
||||
context,
|
||||
link.url,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -557,7 +635,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
],
|
||||
);
|
||||
|
||||
if (!isOutgoing) {
|
||||
if (!isOutgoing && !PlatformInfo.isDesktop) {
|
||||
return _SwipeReplyBubble(
|
||||
maxSwipeOffset: maxSwipeOffset,
|
||||
replySwipeThreshold: replySwipeThreshold,
|
||||
@@ -933,6 +1011,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
Widget _buildMessageComposer() {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
||||
final settings = context.watch<AppSettingsService>().settings;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -964,6 +1043,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: context.l10n.chat_sendGif,
|
||||
),
|
||||
if (settings.translationEnabled)
|
||||
MessageTranslationButton(
|
||||
enabled: settings.composerTranslationEnabled,
|
||||
languageCode: settings.translationTargetLanguageCode,
|
||||
onPressed: _showTranslationOptions,
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
@@ -1041,6 +1126,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
tooltip: context.l10n.chat_sendMessage,
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
@@ -1051,15 +1137,65 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
Future<void> _showTranslationOptions() async {
|
||||
final settingsService = context.read<AppSettingsService>();
|
||||
final settings = settingsService.settings;
|
||||
await showMessageTranslationSheet(
|
||||
context: context,
|
||||
enabled: settings.composerTranslationEnabled,
|
||||
selectedLanguageCode: settings.translationTargetLanguageCode,
|
||||
onEnabledChanged: settingsService.setComposerTranslationEnabled,
|
||||
onLanguageSelected: settingsService.setTranslationTargetLanguageCode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastChannelSendAt != null &&
|
||||
now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
|
||||
return;
|
||||
}
|
||||
_lastChannelSendAt = now;
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final translationService = context.read<TranslationService>();
|
||||
|
||||
String messageText = text;
|
||||
String? originalText;
|
||||
String? translatedLanguageCode;
|
||||
String? translationModelId;
|
||||
if (settings.translationEnabled) {
|
||||
final targetLanguageCode = translationService.resolvedTargetLanguageCode(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
if (translationService.shouldTranslateOutgoing(
|
||||
text: text,
|
||||
targetLanguageCode: targetLanguageCode,
|
||||
)) {
|
||||
final result = await translationService.translateOutgoingText(
|
||||
text: text,
|
||||
targetLanguageCode: targetLanguageCode,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (result != null &&
|
||||
result.status == MessageTranslationStatus.completed &&
|
||||
result.translatedText.isNotEmpty) {
|
||||
messageText = result.translatedText;
|
||||
originalText = text;
|
||||
translatedLanguageCode = result.targetLanguageCode;
|
||||
translationModelId = result.modelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_replyingToMessage != null) {
|
||||
messageText = '@[${_replyingToMessage!.senderName}] $text';
|
||||
messageText = '@[${_replyingToMessage!.senderName}] $messageText';
|
||||
}
|
||||
|
||||
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
||||
@@ -1070,10 +1206,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
connector.sendChannelMessage(widget.channel, messageText);
|
||||
_textController.clear();
|
||||
_cancelReply();
|
||||
_textFieldFocusNode.requestFocus();
|
||||
connector.sendChannelMessage(
|
||||
widget.channel,
|
||||
messageText,
|
||||
originalText: originalText,
|
||||
translatedLanguageCode: translatedLanguageCode,
|
||||
translationModelId: translationModelId,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
@@ -1112,6 +1254,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
_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
|
||||
if (!message.isOutgoing)
|
||||
ListTile(
|
||||
|
||||
@@ -40,8 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final primaryPath = !channelMessage && !message.isOutgoing
|
||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||
: primaryPathTmp;
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||
final hops = _buildPathHops(primaryPath, connector, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
primaryPath.length,
|
||||
@@ -65,6 +64,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
flipPathAround: true,
|
||||
reversePathAround:
|
||||
!(!channelMessage && !message.isOutgoing),
|
||||
pathHashByteWidth: context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -303,10 +305,12 @@ class _ChannelMessagePathMapScreenState
|
||||
extends State<ChannelMessagePathMapScreen> {
|
||||
static const double _labelZoomThreshold = 8.5;
|
||||
|
||||
final MapController _mapController = MapController();
|
||||
Uint8List? _selectedPath;
|
||||
double _pathDistance = 0.0;
|
||||
bool _showNodeLabels = true;
|
||||
bool _didReceivePositionUpdate = false;
|
||||
int? _focusedHopIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -337,6 +341,22 @@ class _ChannelMessagePathMapScreenState
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
void _focusHop(_PathHop hop) {
|
||||
if (!hop.hasLocation) return;
|
||||
final targetZoom = _didReceivePositionUpdate
|
||||
? max(_mapController.camera.zoom, 10.0)
|
||||
: 12.0;
|
||||
_mapController.move(hop.position!, targetZoom);
|
||||
}
|
||||
|
||||
void _onHopTapped(_PathHop hop) {
|
||||
_focusHop(hop);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_focusedHopIndex = hop.index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
@@ -365,8 +385,7 @@ class _ChannelMessagePathMapScreenState
|
||||
: selectedPathTmp;
|
||||
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||
final hops = _buildPathHops(selectedPath, connector, context.l10n);
|
||||
|
||||
final points = <LatLng>[];
|
||||
|
||||
@@ -421,6 +440,7 @@ class _ChannelMessagePathMapScreenState
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: mapKey,
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: initialCenter,
|
||||
initialZoom: initialZoom,
|
||||
@@ -472,6 +492,7 @@ class _ChannelMessagePathMapScreenState
|
||||
) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
_focusedHopIndex = null;
|
||||
});
|
||||
}),
|
||||
if (points.isEmpty)
|
||||
@@ -727,8 +748,17 @@ class _ChannelMessagePathMapScreenState
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final hop = hops[index];
|
||||
final isFocused = _focusedHopIndex == hop.index;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
enabled: hop.hasLocation,
|
||||
selected: isFocused,
|
||||
selectedTileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12),
|
||||
onTap: hop.hasLocation
|
||||
? () => _onHopTapped(hop)
|
||||
: null,
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
child: Text(
|
||||
@@ -787,19 +817,83 @@ class _ObservedPath {
|
||||
|
||||
List<_PathHop> _buildPathHops(
|
||||
Uint8List pathBytes,
|
||||
List<Contact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
if (pathBytes.isEmpty) return const [];
|
||||
final candidatesByPrefix = <int, List<Contact>>{};
|
||||
final allContacts = connector.allContacts;
|
||||
for (final contact in allContacts) {
|
||||
if (contact.publicKey.isEmpty) continue;
|
||||
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
|
||||
continue;
|
||||
}
|
||||
final prefix = contact.publicKey.first;
|
||||
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
|
||||
}
|
||||
for (final candidates in candidatesByPrefix.values) {
|
||||
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
}
|
||||
final startPoint =
|
||||
(connector.selfLatitude != null && connector.selfLongitude != null)
|
||||
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
|
||||
: null;
|
||||
var previousPosition = startPoint;
|
||||
final distance = Distance();
|
||||
var lastDistance = 0.0;
|
||||
var bestDistance = 0.0;
|
||||
final hops = <_PathHop>[];
|
||||
for (var i = 0; i < pathBytes.length; i++) {
|
||||
final prefix = pathBytes[i];
|
||||
final contact = _matchContactForPrefix(contacts, prefix);
|
||||
final searchPoint = i == 0 ? startPoint : previousPosition;
|
||||
final candidates = candidatesByPrefix[pathBytes[i]];
|
||||
Contact? contact;
|
||||
if (candidates != null && candidates.isNotEmpty) {
|
||||
var bestIndex = 0;
|
||||
if (searchPoint != null) {
|
||||
bestDistance = double.infinity;
|
||||
for (var j = 0; j < candidates.length; j++) {
|
||||
final candidate = candidates[j];
|
||||
if (!candidate.hasLocation ||
|
||||
candidate.latitude == null ||
|
||||
candidate.longitude == null) {
|
||||
continue;
|
||||
}
|
||||
final currentDistance = distance(
|
||||
searchPoint,
|
||||
LatLng(candidate.latitude!, candidate.longitude!),
|
||||
);
|
||||
if (currentDistance < bestDistance) {
|
||||
bestDistance = currentDistance;
|
||||
bestIndex = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
contact = candidates.removeAt(bestIndex);
|
||||
if (candidates.isEmpty) {
|
||||
candidatesByPrefix.remove(pathBytes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
final resolvedPosition = _resolvePosition(contact);
|
||||
if (resolvedPosition != null) {
|
||||
previousPosition = resolvedPosition;
|
||||
}
|
||||
// If the best candidate is much farther than the previous hop, it's likely not the correct match.
|
||||
if (lastDistance + bestDistance > 50000 &&
|
||||
candidates != null &&
|
||||
candidates.isNotEmpty) {
|
||||
i--;
|
||||
lastDistance = bestDistance;
|
||||
continue;
|
||||
}
|
||||
lastDistance = bestDistance;
|
||||
|
||||
hops.add(
|
||||
_PathHop(
|
||||
index: i + 1,
|
||||
prefix: prefix,
|
||||
prefix: pathBytes[i],
|
||||
contact: contact,
|
||||
position: _resolvePosition(contact),
|
||||
position: resolvedPosition,
|
||||
l10n: l10n,
|
||||
),
|
||||
);
|
||||
@@ -807,42 +901,13 @@ List<_PathHop> _buildPathHops(
|
||||
return hops;
|
||||
}
|
||||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where(
|
||||
(contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix,
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
Contact? pickWhere(bool Function(Contact) predicate) {
|
||||
for (final contact in matches) {
|
||||
if (predicate(contact)) return contact;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ??
|
||||
pickWhere((c) => c.type == advTypeRepeater) ??
|
||||
pickWhere(_hasValidLocation) ??
|
||||
matches.first;
|
||||
}
|
||||
|
||||
LatLng? _resolvePosition(Contact? contact) {
|
||||
if (contact == null) return null;
|
||||
if (!_hasValidLocation(contact)) return null;
|
||||
return LatLng(contact.latitude!, contact.longitude!);
|
||||
}
|
||||
|
||||
bool _hasValidLocation(Contact contact) {
|
||||
final lat = contact.latitude;
|
||||
final lon = contact.longitude;
|
||||
if (lat == null || lon == null) return false;
|
||||
if (lat == 0 && lon == 0) return false;
|
||||
return true;
|
||||
if (!contact.hasLocation) return null;
|
||||
final latitude = contact.latitude;
|
||||
final longitude = contact.longitude;
|
||||
if (latitude == null || longitude == null) return null;
|
||||
return LatLng(latitude, longitude);
|
||||
}
|
||||
|
||||
String _formatPrefix(int prefix) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.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:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
@@ -417,78 +418,96 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
return Card(
|
||||
key: ValueKey('channel_${channel.index}'),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
minVerticalPadding: 0,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
visualDensity: const VisualDensity(vertical: -2),
|
||||
leading: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: bgColor,
|
||||
child: Icon(icon, color: iconColor),
|
||||
),
|
||||
if (isCommunityChannel)
|
||||
Positioned(
|
||||
right: 0,
|
||||
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: GestureDetector(
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||
? (_) => _showChannelActions(
|
||||
context,
|
||||
connector,
|
||||
channelMessageStore,
|
||||
channel,
|
||||
)
|
||||
: null,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
minVerticalPadding: 0,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
visualDensity: const VisualDensity(vertical: -2),
|
||||
leading: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: bgColor,
|
||||
child: Icon(icon, color: iconColor),
|
||||
),
|
||||
if (isCommunityChannel)
|
||||
Positioned(
|
||||
right: 0,
|
||||
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(
|
||||
index: dragIndex,
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
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(
|
||||
index: dragIndex,
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
connector.markChannelRead(channel.index);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (context.mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelChatScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _showChannelActions(
|
||||
context,
|
||||
connector,
|
||||
channelMessageStore,
|
||||
channel,
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
connector.markChannelRead(channel.index);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (context.mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelChatScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _showChannelActions(
|
||||
context,
|
||||
connector,
|
||||
channelMessageStore,
|
||||
channel,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+468
-110
@@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../utils/platform_info.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
@@ -15,15 +16,17 @@ import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/reaction_helper.dart';
|
||||
import '../widgets/message_status_icon.dart';
|
||||
import '../helpers/chat_scroll_controller.dart';
|
||||
import '../helpers/link_handler.dart';
|
||||
import '../helpers/path_helper.dart';
|
||||
import '../helpers/utf8_length_limiter.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/path_history.dart';
|
||||
import '../models/translation_support.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/chat_text_scale_service.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import '../services/translation_service.dart';
|
||||
import '../widgets/chat_zoom_wrapper.dart';
|
||||
import '../widgets/elements_ui.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
@@ -33,9 +36,13 @@ import '../widgets/emoji_picker.dart';
|
||||
import '../widgets/gif_message.dart';
|
||||
import '../widgets/jump_to_bottom_button.dart';
|
||||
import '../widgets/gif_picker.dart';
|
||||
import '../widgets/message_translation_button.dart';
|
||||
import '../widgets/path_selection_dialog.dart';
|
||||
import '../widgets/radio_stats_entry.dart';
|
||||
import '../widgets/translated_message_content.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'telemetry_screen.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final Contact contact;
|
||||
@@ -50,8 +57,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final _textController = TextEditingController();
|
||||
final _scrollController = ChatScrollController();
|
||||
final _textFieldFocusNode = FocusNode();
|
||||
final GlobalKey _unreadScrollKey = GlobalKey();
|
||||
bool _isLoadingOlder = false;
|
||||
MeshCoreConnector? _connector;
|
||||
Message? _pendingUnreadScrollTarget;
|
||||
DateTime? _lastTextSendAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,11 +70,50 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_connector = context.read<MeshCoreConnector>();
|
||||
_connector?.setActiveContact(widget.contact.publicKeyHex);
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final keyHex = widget.contact.publicKeyHex;
|
||||
final unread = connector.getUnreadCountForContactKey(keyHex);
|
||||
Message? anchor;
|
||||
if (settings.jumpToOldestUnread && unread > 0) {
|
||||
anchor = _findOldestUnreadAnchor(
|
||||
connector.getMessages(widget.contact),
|
||||
unread,
|
||||
);
|
||||
}
|
||||
connector.setActiveContact(keyHex);
|
||||
_connector = connector;
|
||||
if (anchor != null) {
|
||||
setState(() => _pendingUnreadScrollTarget = anchor);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final ctx = _unreadScrollKey.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
alignment: 0.15,
|
||||
);
|
||||
}
|
||||
setState(() => _pendingUnreadScrollTarget = null);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Message? _findOldestUnreadAnchor(List<Message> messages, int unreadCount) {
|
||||
if (unreadCount <= 0 || messages.isEmpty) return null;
|
||||
var n = 0;
|
||||
Message? oldest;
|
||||
for (final m in messages.reversed) {
|
||||
if (m.isOutgoing || m.isCli) continue;
|
||||
n++;
|
||||
oldest = m;
|
||||
if (n >= unreadCount) break;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
void _onTextFieldFocusChange() {
|
||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||
_scrollController.handleKeyboardOpen();
|
||||
@@ -244,9 +293,78 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
tooltip: context.l10n.chat_pathManagement,
|
||||
onPressed: () => _showPathHistory(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => _showContactInfo(context),
|
||||
const RadioStatsIconButton(),
|
||||
Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -307,6 +425,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (_pendingUnreadScrollTarget != null) return;
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
@@ -353,7 +472,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
(service) => service.scale,
|
||||
);
|
||||
final resolvedContact = _resolveContact(connector);
|
||||
return _MessageBubble(
|
||||
final bubble = _MessageBubble(
|
||||
message: message,
|
||||
senderName: resolvedContact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
@@ -362,7 +481,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
textScale: textScale,
|
||||
onTap: () => _openMessagePath(message, contact),
|
||||
onLongPress: () => _showMessageActions(message, contact),
|
||||
onRetryReaction: (msg, emoji) =>
|
||||
_sendReaction(msg, contact, emoji),
|
||||
);
|
||||
if (identical(message, _pendingUnreadScrollTarget)) {
|
||||
return KeyedSubtree(key: _unreadScrollKey, child: bubble);
|
||||
}
|
||||
return bubble;
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -373,6 +498,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
Widget _buildInputBar(MeshCoreConnector connector) {
|
||||
final maxBytes = maxContactMessageBytes();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = context.watch<AppSettingsService>().settings;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -387,6 +513,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: context.l10n.chat_sendGif,
|
||||
),
|
||||
if (settings.translationEnabled)
|
||||
MessageTranslationButton(
|
||||
enabled: settings.composerTranslationEnabled,
|
||||
languageCode: settings.translationTargetLanguageCode,
|
||||
onPressed: _showTranslationOptions,
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
@@ -458,6 +590,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
tooltip: context.l10n.chat_sendMessageTo(
|
||||
_resolveContact(connector).name,
|
||||
),
|
||||
onPressed: () => _sendMessage(connector),
|
||||
),
|
||||
],
|
||||
@@ -484,21 +619,78 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _sendMessage(MeshCoreConnector connector) {
|
||||
Future<void> _showTranslationOptions() async {
|
||||
final settingsService = context.read<AppSettingsService>();
|
||||
final settings = settingsService.settings;
|
||||
await showMessageTranslationSheet(
|
||||
context: context,
|
||||
enabled: settings.composerTranslationEnabled,
|
||||
selectedLanguageCode: settings.translationTargetLanguageCode,
|
||||
onEnabledChanged: settingsService.setComposerTranslationEnabled,
|
||||
onLanguageSelected: settingsService.setTranslationTargetLanguageCode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendMessage(MeshCoreConnector connector) async {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastTextSendAt != null &&
|
||||
now.difference(_lastTextSendAt!) < const Duration(seconds: 1)) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
|
||||
return;
|
||||
}
|
||||
_lastTextSendAt = now;
|
||||
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final translationService = context.read<TranslationService>();
|
||||
var outgoingText = text;
|
||||
String? originalText;
|
||||
String? translatedLanguageCode;
|
||||
String? translationModelId;
|
||||
if (settings.translationEnabled) {
|
||||
final targetLanguageCode = translationService.resolvedTargetLanguageCode(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
if (translationService.shouldTranslateOutgoing(
|
||||
text: text,
|
||||
targetLanguageCode: targetLanguageCode,
|
||||
)) {
|
||||
final result = await translationService.translateOutgoingText(
|
||||
text: text,
|
||||
targetLanguageCode: targetLanguageCode,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (result != null &&
|
||||
result.status == MessageTranslationStatus.completed &&
|
||||
result.translatedText.isNotEmpty) {
|
||||
outgoingText = result.translatedText;
|
||||
originalText = text;
|
||||
translatedLanguageCode = result.targetLanguageCode;
|
||||
translationModelId = result.modelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
final maxBytes = maxContactMessageBytes();
|
||||
if (utf8.encode(text).length > maxBytes) {
|
||||
if (utf8.encode(outgoingText).length > maxBytes) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
connector.sendMessage(_resolveContact(connector), text);
|
||||
_textController.clear();
|
||||
_textFieldFocusNode.requestFocus();
|
||||
connector.sendMessage(
|
||||
_resolveContact(connector),
|
||||
outgoingText,
|
||||
originalText: originalText,
|
||||
translatedLanguageCode: translatedLanguageCode,
|
||||
translationModelId: translationModelId,
|
||||
);
|
||||
}
|
||||
|
||||
void _showPathHistory(BuildContext context) {
|
||||
@@ -820,7 +1012,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatRelativeTime(DateTime time) {
|
||||
String _formatRelativeTime(DateTime? time) {
|
||||
if (time == null) return '—';
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return context.l10n.time_justNow;
|
||||
if (diff.inMinutes < 60) {
|
||||
@@ -841,15 +1034,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final formattedPath = pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final allContacts = connector.allContacts;
|
||||
|
||||
final formattedPath = PathHelper.formatPathHex(pathBytes);
|
||||
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
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: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
@@ -860,6 +1069,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathAround: true,
|
||||
targetContact: widget.contact,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -874,11 +1084,22 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
int _resolveContactIndex = -1;
|
||||
|
||||
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,
|
||||
orElse: () => widget.contact,
|
||||
);
|
||||
if (_resolveContactIndex == -1) {
|
||||
return widget.contact;
|
||||
}
|
||||
return connector.contacts[_resolveContactIndex];
|
||||
}
|
||||
|
||||
Contact _resolveContactFrom4Bytes(
|
||||
@@ -931,59 +1152,127 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
void _showContactInfo(BuildContext context) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
||||
|
||||
final contact = _resolveContact(connector);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final contact = _resolveContact(connector);
|
||||
final smazEnabled = connector.isContactSmazEnabled(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(contact.name),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
builder: (context) => AlertDialog(
|
||||
title: SelectableText(contact.name),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
|
||||
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
|
||||
_buildInfoRow(
|
||||
context.l10n.contact_lastSeen,
|
||||
_formatContactLastMessage(contact.lastMessageAt),
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
_buildInfoRow(
|
||||
context.l10n.chat_location,
|
||||
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||
),
|
||||
_buildInfoRow(context.l10n.chat_publicKey, contact.publicKeyHex),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContactSettings(BuildContext context) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
||||
final contact = widget.contact;
|
||||
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
|
||||
bool teleBaseEnabled = contact.teleBaseEnabled;
|
||||
bool teleLocEnabled = contact.teleLocEnabled;
|
||||
bool teleEnvEnabled = contact.teleEnvEnabled;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(context.l10n.contact_settings),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (contact.hasLocation) ...[
|
||||
_buildInfoRow(
|
||||
context.l10n.chat_location,
|
||||
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||
),
|
||||
const Divider(height: 8),
|
||||
],
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.channels_smazCompression),
|
||||
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
|
||||
value: smazEnabled,
|
||||
onChanged: (value) {
|
||||
connector.setContactSmazEnabled(
|
||||
contact.publicKeyHex,
|
||||
value,
|
||||
);
|
||||
setDialogState(() => smazEnabled = value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.contact_teleBase),
|
||||
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
|
||||
value: teleBaseEnabled,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => teleBaseEnabled = value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.contact_teleLoc),
|
||||
subtitle: Text(context.l10n.contact_teleLocSubtitle),
|
||||
value: teleLocEnabled,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => teleLocEnabled = value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.contact_teleEnv),
|
||||
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
|
||||
value: teleEnvEnabled,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => teleEnvEnabled = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
connector.setContactFlags(
|
||||
contact,
|
||||
teleBase: teleBaseEnabled,
|
||||
teleLoc: teleLocEnabled,
|
||||
teleEnv: teleEnvEnabled,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -998,12 +1287,32 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
width: 80,
|
||||
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) {
|
||||
// Check if this is a repeater
|
||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
||||
@@ -1023,7 +1332,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
final pathForInput = currentContact.pathIdList;
|
||||
final pathForInput = currentContact.pathFormattedIdList(
|
||||
connector.pathHashByteWidth,
|
||||
);
|
||||
final currentPathLabel = _currentPathLabel(currentContact);
|
||||
|
||||
// Filter out the current contact from available contacts
|
||||
@@ -1127,6 +1438,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_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(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: Text(context.l10n.common_copy),
|
||||
@@ -1237,6 +1557,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
final bool isRoomServer;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final void Function(Message message, String emoji)? onRetryReaction;
|
||||
final double textScale;
|
||||
|
||||
const _MessageBubble({
|
||||
@@ -1246,6 +1567,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
required this.textScale,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onRetryReaction,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1271,6 +1593,14 @@ class _MessageBubble extends StatelessWidget {
|
||||
if (isRoomServer && !isOutgoing) {
|
||||
messageText = message.text.substring(4.clamp(0, message.text.length));
|
||||
}
|
||||
final translatedDisplayText =
|
||||
message.translatedText != null &&
|
||||
message.translatedText!.trim().isNotEmpty
|
||||
? message.translatedText!.trim()
|
||||
: messageText;
|
||||
final originalDisplayText = isOutgoing
|
||||
? message.originalText
|
||||
: (translatedDisplayText != messageText ? messageText : null);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
@@ -1279,8 +1609,11 @@ class _MessageBubble extends StatelessWidget {
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: PlatformInfo.isDesktop ? null : onTap,
|
||||
onLongPress: onLongPress,
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||
? (_) => onLongPress?.call()
|
||||
: null,
|
||||
child: Row(
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
@@ -1397,26 +1730,17 @@ class _MessageBubble extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Linkify(
|
||||
text: messageText,
|
||||
child: TranslatedMessageContent(
|
||||
displayText: translatedDisplayText,
|
||||
originalText: originalDisplayText,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
originalStyle: TextStyle(
|
||||
color: textColor.withValues(alpha: 0.78),
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) => LinkHandler.handleLinkTap(
|
||||
context,
|
||||
link.url,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!enableTracing && isOutgoing) ...[
|
||||
@@ -1445,7 +1769,10 @@ class _MessageBubble extends StatelessWidget {
|
||||
child: Text(
|
||||
context.l10n.chat_retryCount(
|
||||
message.retryCount,
|
||||
4,
|
||||
context
|
||||
.read<AppSettingsService>()
|
||||
.settings
|
||||
.maxMessageRetries,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
@@ -1606,33 +1933,64 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: message.reactions.entries.map((entry) {
|
||||
final emoji = entry.key;
|
||||
final count = entry.value;
|
||||
final status = message.reactionStatuses[emoji];
|
||||
final isPending =
|
||||
status == MessageStatus.pending || status == MessageStatus.sent;
|
||||
final isFailed = status == MessageStatus.failed;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
return GestureDetector(
|
||||
onTap: isFailed && onRetryReaction != null
|
||||
? () => onRetryReaction!(message, emoji)
|
||||
: null,
|
||||
child: Opacity(
|
||||
opacity: isPending ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isFailed
|
||||
? colorScheme.error
|
||||
: colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
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(),
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/models/companion_radio_stats.dart';
|
||||
import 'package:meshcore_open/l10n/l10n.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class CompanionRadioStatsScreen extends StatefulWidget {
|
||||
const CompanionRadioStatsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CompanionRadioStatsScreen> createState() =>
|
||||
_CompanionRadioStatsScreenState();
|
||||
}
|
||||
|
||||
class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
|
||||
final List<double> _noiseHistory = [];
|
||||
static const int _maxSamples = 120;
|
||||
MeshCoreConnector? _connector;
|
||||
DateTime? _lastChartSampleAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final c = context.read<MeshCoreConnector>();
|
||||
_connector = c;
|
||||
c.acquireRadioStatsPolling();
|
||||
c.setPollingInterval(1);
|
||||
c.radioStatsNotifier.addListener(_onStatsUpdate);
|
||||
}
|
||||
|
||||
void _onStatsUpdate() {
|
||||
final s = _connector?.radioStatsNotifier.value;
|
||||
if (s == null || !mounted) return;
|
||||
if (_lastChartSampleAt == s.receivedAt) return;
|
||||
_lastChartSampleAt = s.receivedAt;
|
||||
setState(() {
|
||||
_noiseHistory.add(s.noiseFloorDbm.toDouble());
|
||||
while (_noiseHistory.length > _maxSamples) {
|
||||
_noiseHistory.removeAt(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connector?.radioStatsNotifier.removeListener(_onStatsUpdate);
|
||||
_connector?.releaseRadioStatsPolling();
|
||||
_connector?.setPollingInterval(30);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.radioStats_screenTitle),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Selector<MeshCoreConnector, ({bool connected, bool supported})>(
|
||||
selector: (_, c) => (
|
||||
connected: c.isConnected,
|
||||
supported: c.supportsCompanionRadioStats,
|
||||
),
|
||||
builder: (context, state, _) {
|
||||
if (!state.connected) {
|
||||
return Center(child: Text(l10n.radioStats_notConnected));
|
||||
}
|
||||
if (!state.supported) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
l10n.radioStats_firmwareTooOld,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
return ValueListenableBuilder<CompanionRadioStats?>(
|
||||
valueListenable: connector.radioStatsNotifier,
|
||||
builder: (context, stats, _) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (stats != null) ...[
|
||||
Text(
|
||||
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
|
||||
style: tt.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)),
|
||||
Text(
|
||||
l10n.radioStats_lastSnr(
|
||||
stats.lastSnrDb.toStringAsFixed(1),
|
||||
),
|
||||
),
|
||||
Text(l10n.radioStats_txAir(stats.txAirSecs)),
|
||||
Text(l10n.radioStats_rxAir(stats.rxAirSecs)),
|
||||
const SizedBox(height: 16),
|
||||
] else
|
||||
Text(l10n.radioStats_waiting),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: CustomPaint(
|
||||
painter: _NoiseChartPainter(
|
||||
samples: List<double>.from(_noiseHistory),
|
||||
colorScheme: scheme,
|
||||
textTheme: tt,
|
||||
),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.radioStats_chartCaption,
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoiseChartPainter extends CustomPainter {
|
||||
final List<double> samples;
|
||||
final ColorScheme colorScheme;
|
||||
final TextTheme textTheme;
|
||||
|
||||
_NoiseChartPainter({
|
||||
required this.samples,
|
||||
required this.colorScheme,
|
||||
required this.textTheme,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final bg = Paint()..color = colorScheme.surfaceContainerHighest;
|
||||
final border = Paint()
|
||||
..color = colorScheme.outlineVariant
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1;
|
||||
final grid = Paint()
|
||||
..color = colorScheme.outlineVariant.withValues(alpha: 0.5)
|
||||
..strokeWidth = 1;
|
||||
final line = Paint()
|
||||
..color = colorScheme.primary
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final rect = Rect.fromLTWH(0, 0, size.width, size.height);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
|
||||
bg,
|
||||
);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
|
||||
border,
|
||||
);
|
||||
|
||||
const padL = 40.0;
|
||||
const padR = 8.0;
|
||||
const padT = 8.0;
|
||||
const padB = 24.0;
|
||||
final chart = Rect.fromLTRB(
|
||||
padL,
|
||||
padT,
|
||||
size.width - padR,
|
||||
size.height - padB,
|
||||
);
|
||||
|
||||
for (var i = 0; i <= 4; i++) {
|
||||
final y = chart.top + (chart.height * i / 4);
|
||||
canvas.drawLine(Offset(chart.left, y), Offset(chart.right, y), grid);
|
||||
}
|
||||
|
||||
if (samples.length < 2) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '—',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(
|
||||
canvas,
|
||||
Offset(chart.left + 4, chart.top + chart.height / 2 - tp.height / 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
double minV = samples.reduce((a, b) => a < b ? a : b);
|
||||
double maxV = samples.reduce((a, b) => a > b ? a : b);
|
||||
if ((maxV - minV).abs() < 1) {
|
||||
minV -= 2;
|
||||
maxV += 2;
|
||||
}
|
||||
final span = maxV - minV;
|
||||
|
||||
for (var i = 0; i <= 2; i++) {
|
||||
final v = maxV - span * i / 2;
|
||||
final tp = _yAxisLabel(v);
|
||||
final y = chart.top + (chart.height * i / 2) - tp.height / 2;
|
||||
tp.paint(canvas, Offset(4, y));
|
||||
}
|
||||
|
||||
final path = Path();
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
final x = chart.left + (chart.width * i / (samples.length - 1));
|
||||
final t = (samples[i] - minV) / span;
|
||||
final y = chart.bottom - t * chart.height;
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
canvas.drawPath(path, line);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _NoiseChartPainter oldDelegate) {
|
||||
return oldDelegate.samples.length != samples.length ||
|
||||
oldDelegate.colorScheme != colorScheme;
|
||||
}
|
||||
|
||||
TextPainter _yAxisLabel(double v) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: v.round().toString(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return tp;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/services.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/platform_info.dart';
|
||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -393,7 +394,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
children: [
|
||||
const Icon(Icons.person_add_rounded),
|
||||
const SizedBox(width: 8),
|
||||
Text("Discovered Contacts"),
|
||||
Text(context.l10n.discoveredContacts_Title),
|
||||
],
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
@@ -1239,20 +1240,19 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
if (isRepeater) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
title: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? context.l10n.contacts_repeaterPathTrace
|
||||
: context.l10n.contacts_repeaterPing,
|
||||
path: contact.pathBytesForDisplay,
|
||||
flipPathAround: true,
|
||||
title: context.l10n.contacts_repeaterPing,
|
||||
path: Uint8List.fromList([contact.publicKey.first]),
|
||||
targetContact: contact,
|
||||
pathHashByteWidth: hw,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1269,10 +1269,11 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
] else if (isRoom) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
title: Text(context.l10n.contacts_pathTrace),
|
||||
onTap: () {
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -1280,9 +1281,12 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? context.l10n.contacts_roomPathTrace
|
||||
: context.l10n.contacts_roomPing,
|
||||
path: contact.pathBytesForDisplay,
|
||||
path: contact.pathBytesForDisplay.isNotEmpty
|
||||
? contact.pathBytesForDisplay
|
||||
: Uint8List.fromList([contact.publicKey.first]),
|
||||
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
|
||||
targetContact: contact,
|
||||
pathHashByteWidth: hw,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1317,6 +1321,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -1327,6 +1334,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
path: contact.pathBytesForDisplay,
|
||||
flipPathAround: true,
|
||||
targetContact: contact,
|
||||
pathHashByteWidth: hw,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1353,7 +1361,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
await connector.setContactFavorite(contact, !isFavorite);
|
||||
await connector.setContactFlags(
|
||||
contact,
|
||||
isFavorite: !isFavorite,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -1439,66 +1450,77 @@ class _ContactTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
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),
|
||||
),
|
||||
return GestureDetector(
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
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]),
|
||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
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(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// Main hub screen after connecting to a MeshCore device
|
||||
class DeviceScreen extends StatefulWidget {
|
||||
const DeviceScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeviceScreen> createState() => _DeviceScreenState();
|
||||
}
|
||||
|
||||
class _DeviceScreenState extends State<DeviceScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
bool _showBatteryVoltage = false;
|
||||
int _quickIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
// Auto-navigate back to scanner if disconnected
|
||||
if (!checkConnectionAndNavigate(connector)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: _buildBatteryIndicator(connector, context),
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
title: _buildAppBarTitle(connector, theme),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: context.l10n.common_disconnect,
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: context.l10n.common_settings,
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
children: [
|
||||
_buildConnectionCard(connector, context),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionLabel(theme, context.l10n.device_quickSwitch),
|
||||
const SizedBox(height: 12),
|
||||
_buildQuickSwitchBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBarTitle(MeshCoreConnector connector, ThemeData theme) {
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.device_meshcore,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
connector.deviceDisplayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionLabel(ThemeData theme, String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectionCard(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.wifi_tethering_rounded,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
connector.deviceDisplayName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
connector.deviceIdLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Chip(
|
||||
avatar: Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
label: Text(context.l10n.common_connected),
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
_buildBatteryIndicator(connector, context),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickSwitchBar(BuildContext context) {
|
||||
return QuickSwitchBar(
|
||||
selectedIndex: _quickIndex,
|
||||
onDestinationSelected: (index) {
|
||||
_openQuickDestination(index, context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatteryIndicator(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final percent = connector.batteryPercent;
|
||||
final millivolts = connector.batteryMillivolts;
|
||||
final percentLabel = percent != null ? '$percent%' : '--%';
|
||||
final voltageLabel = millivolts == null
|
||||
? '-- V'
|
||||
: '${(millivolts / 1000.0).toStringAsFixed(2)} V';
|
||||
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
|
||||
final icon = _batteryIcon(percent);
|
||||
|
||||
return ActionChip(
|
||||
avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
label: Text(displayLabel),
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showBatteryVoltage = !_showBatteryVoltage;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
IconData _batteryIcon(int? percent) {
|
||||
if (percent == null) return Icons.battery_unknown;
|
||||
if (percent <= 15) return Icons.battery_alert;
|
||||
return Icons.battery_full;
|
||||
}
|
||||
|
||||
void _openQuickDestination(int index, BuildContext context) {
|
||||
if (_quickIndex != index) {
|
||||
setState(() {
|
||||
_quickIndex = index;
|
||||
});
|
||||
}
|
||||
switch (index) {
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
|
||||
@@ -37,6 +38,13 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
DateTime _resolveLastSeen(Contact contact) {
|
||||
if (contact.type != advTypeChat) return contact.lastSeen;
|
||||
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
||||
? contact.lastMessageAt
|
||||
: contact.lastSeen;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
@@ -88,7 +96,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
itemCount: filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredAndSorted[index];
|
||||
return ListTile(
|
||||
final tile = ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: Icon(
|
||||
@@ -107,11 +115,56 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
_formatLastSeen(context, contact.lastSeen),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
// 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(
|
||||
width: 120,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatLastSeen(
|
||||
context,
|
||||
_resolveLastSeen(contact),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (contact.hasLocation)
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
if (contact.rawPacket != null)
|
||||
const SizedBox(width: 2),
|
||||
if (contact.rawPacket != null)
|
||||
Icon(
|
||||
Icons.cell_tower,
|
||||
size: 14,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
@@ -120,6 +173,14 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
onLongPress: () =>
|
||||
_showContactContextMenu(contact, connector),
|
||||
);
|
||||
if (PlatformInfo.isDesktop) {
|
||||
return GestureDetector(
|
||||
onSecondaryTapUp: (_) =>
|
||||
_showContactContextMenu(contact, connector),
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
return tile;
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
+297
-105
@@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -52,7 +53,7 @@ class MapScreen extends StatefulWidget {
|
||||
|
||||
class _MapScreenState extends State<MapScreen> {
|
||||
// Zoom level at which node labels start to appear
|
||||
static const double _labelZoomThreshold = 12.0;
|
||||
static const double _labelZoomThreshold = 14.0;
|
||||
|
||||
final MapController _mapController = MapController();
|
||||
final MapMarkerService _markerService = MapMarkerService();
|
||||
@@ -63,6 +64,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
bool _hasInitializedMap = false;
|
||||
bool _removedMarkersLoaded = false;
|
||||
final List<int> _pathTrace = [];
|
||||
final List<Contact> _pathTraceContacts = [];
|
||||
final List<LatLng> _points = [];
|
||||
final List<Polyline> _polylines = [];
|
||||
bool _legendExpanded = false;
|
||||
@@ -329,7 +331,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
if (!_isBuildingPathTrace)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.radar),
|
||||
onPressed: () => _startPath(),
|
||||
onPressed: () => _startPath(
|
||||
LatLng(connector.selfLatitude!, connector.selfLongitude!),
|
||||
),
|
||||
tooltip: context.l10n.contacts_pathTrace,
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
@@ -477,13 +481,15 @@ class _MapScreenState extends State<MapScreen> {
|
||||
point: highlightPosition,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.red[600],
|
||||
size: 34,
|
||||
child: IgnorePointer(
|
||||
child: Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.red[600],
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
if (!settings.mapShowOverlaps)
|
||||
..._buildGuessedMarker(
|
||||
guessedLocations,
|
||||
showLabels: _showNodeLabels,
|
||||
@@ -503,28 +509,33 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.teal,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
child: IgnorePointer(
|
||||
ignoring: true,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.teal,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.person_pin_circle,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.person_pin_circle,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -544,6 +555,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
_buildLegend(
|
||||
contacts,
|
||||
contactsWithLocation,
|
||||
settings,
|
||||
sharedMarkers.length,
|
||||
@@ -580,6 +592,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
// Index known-location repeaters by their 1-byte hash.
|
||||
// null value = two repeaters share the same hash byte (ambiguous collision).
|
||||
final repeaterByHash = <int, Contact?>{};
|
||||
|
||||
for (final c in withLocation) {
|
||||
if (c.type == advTypeRepeater) {
|
||||
if (repeaterByHash.containsKey(c.publicKey[0])) {
|
||||
@@ -595,6 +608,11 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
for (final contact in allContacts) {
|
||||
if (contact.hasLocation) continue;
|
||||
if (contact.lastSeen.isBefore(
|
||||
DateTime.now().subtract(const Duration(hours: 24)),
|
||||
)) {
|
||||
continue; // skip stale contacts
|
||||
}
|
||||
|
||||
final anchorSet = <LatLng>{};
|
||||
|
||||
@@ -617,19 +635,6 @@ class _MapScreenState extends State<MapScreen> {
|
||||
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.
|
||||
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
||||
// range of the same node, so isolated outliers are removed.
|
||||
@@ -641,15 +646,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
final LatLng position;
|
||||
if (anchors.length == 1) {
|
||||
// Offset single-anchor guesses so they don't overlap the repeater marker.
|
||||
// Use the contact's public key byte as a deterministic angle seed.
|
||||
const offsetDeg = 0.003; // ~330 m at the equator
|
||||
final angle = (contact.publicKey[1] / 255.0) * 2 * pi;
|
||||
position = LatLng(
|
||||
anchors[0].latitude + offsetDeg * cos(angle),
|
||||
anchors[0].longitude + offsetDeg * sin(angle),
|
||||
// Spread single-anchor guesses around the anchor so they remain visible.
|
||||
position = _offsetGuessedPosition(
|
||||
anchors[0],
|
||||
contact,
|
||||
radiusMeters: 330,
|
||||
);
|
||||
|
||||
if (!_checkLocationPlausibility(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
@@ -657,12 +659,25 @@ class _MapScreenState extends State<MapScreen> {
|
||||
continue; // discard implausible guesses near (0, 0)
|
||||
}
|
||||
} else {
|
||||
double lat = 0, lon = 0;
|
||||
double lat = 0, lon = 0, weight = 1.0;
|
||||
int counted = 0;
|
||||
for (final a in anchors) {
|
||||
lat += a.latitude;
|
||||
lon += a.longitude;
|
||||
if (counted == 0) {
|
||||
lat = a.latitude;
|
||||
lon = a.longitude;
|
||||
} else {
|
||||
lat += a.latitude * weight;
|
||||
lon += a.longitude * weight;
|
||||
}
|
||||
// weight subsequent anchors less to create a bias towards the first (if more than 2)
|
||||
weight = weight / 2;
|
||||
counted++;
|
||||
}
|
||||
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(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
@@ -682,6 +697,31 @@ class _MapScreenState extends State<MapScreen> {
|
||||
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
|
||||
/// device's current radio parameters. Returns null if parameters are unknown.
|
||||
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
||||
@@ -749,17 +789,26 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final markers = <Marker>[];
|
||||
|
||||
for (final guess in guessed) {
|
||||
if (guess.contact.type == advTypeChat && _isBuildingPathTrace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final color = _getNodeColor(guess.contact.type);
|
||||
final marker = Marker(
|
||||
point: guess.position,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showNodeInfo(
|
||||
context,
|
||||
guess.contact,
|
||||
guessedPosition: guess.position,
|
||||
),
|
||||
onLongPress: () => _isBuildingPathTrace
|
||||
? _showNodeInfo(context, guess.contact)
|
||||
: null,
|
||||
onTap: () => _isBuildingPathTrace
|
||||
? _addToPath(context, guess.contact, position: guess.position)
|
||||
: _showNodeInfo(
|
||||
context,
|
||||
guess.contact,
|
||||
guessedPosition: guess.position,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -799,31 +848,76 @@ class _MapScreenState extends State<MapScreen> {
|
||||
return markers;
|
||||
}
|
||||
|
||||
List<Contact> _filterContactsBySettings(
|
||||
List<Contact> contacts,
|
||||
dynamic settings, {
|
||||
bool noLocations = false,
|
||||
}) {
|
||||
List<Contact> filtered = [];
|
||||
bool addContact = false;
|
||||
for (final contact in contacts) {
|
||||
addContact = false;
|
||||
if (!contact.hasLocation && !noLocations) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply node type filters
|
||||
if (contact.type == advTypeRepeater &&
|
||||
(settings.mapShowRepeaters ||
|
||||
_isBuildingPathTrace ||
|
||||
settings.mapShowOverlaps)) {
|
||||
addContact = true;
|
||||
}
|
||||
if (contact.type == advTypeChat &&
|
||||
(settings.mapShowChatNodes || _isBuildingPathTrace)) {
|
||||
addContact = true;
|
||||
}
|
||||
if (contact.type != advTypeChat &&
|
||||
contact.type != advTypeRepeater &&
|
||||
(settings.mapShowOtherNodes ||
|
||||
_isBuildingPathTrace ||
|
||||
settings.mapShowOverlaps)) {
|
||||
addContact = true;
|
||||
}
|
||||
|
||||
if (contact.type == advTypeChat && _isBuildingPathTrace) {
|
||||
addContact = false;
|
||||
}
|
||||
|
||||
if (settings.mapShowOverlaps) {
|
||||
final hasOverlap = contacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.publicKeyHex != contact.publicKeyHex &&
|
||||
c.publicKey.first == contact.publicKey.first &&
|
||||
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
|
||||
(contact.type == advTypeRepeater ||
|
||||
contact.type == advTypeRoom),
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
if (hasOverlap == null &&
|
||||
settings.mapShowOverlaps &&
|
||||
!_isBuildingPathTrace) {
|
||||
addContact = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (addContact) {
|
||||
filtered.add(contact);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<Marker> _buildMarkers(
|
||||
List<Contact> contacts,
|
||||
settings, {
|
||||
required bool showLabels,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
|
||||
for (final contact in contacts) {
|
||||
if (!contact.hasLocation) continue;
|
||||
|
||||
// Apply node type filters
|
||||
if (contact.type == advTypeRepeater &&
|
||||
(!settings.mapShowRepeaters && !_isBuildingPathTrace)) {
|
||||
continue;
|
||||
}
|
||||
if (contact.type == advTypeChat &&
|
||||
!(settings.mapShowChatNodes && !_isBuildingPathTrace)) {
|
||||
continue;
|
||||
}
|
||||
if (contact.type != advTypeChat &&
|
||||
contact.type != advTypeRepeater &&
|
||||
(!settings.mapShowOtherNodes && !_isBuildingPathTrace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final filteredContacts = _filterContactsBySettings(contacts, settings);
|
||||
for (final contact in filteredContacts) {
|
||||
final marker = Marker(
|
||||
point: LatLng(contact.latitude!, contact.longitude!),
|
||||
width: 35,
|
||||
@@ -839,7 +933,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getNodeColor(contact.type),
|
||||
color: settings.mapShowOverlaps && !_isBuildingPathTrace
|
||||
? Colors.red
|
||||
: _getNodeColor(contact.type),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
@@ -866,7 +962,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: LatLng(contact.latitude!, contact.longitude!),
|
||||
label: contact.name,
|
||||
label: settings.mapShowOverlaps && !_isBuildingPathTrace
|
||||
? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}"
|
||||
: contact.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -941,25 +1039,25 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
|
||||
Widget _buildLegend(
|
||||
List<Contact> contacts,
|
||||
List<Contact> contactsWithLocation,
|
||||
settings,
|
||||
int markerCount,
|
||||
int guessedCount,
|
||||
) {
|
||||
int nodeCount = 0;
|
||||
for (final contact in contactsWithLocation) {
|
||||
// Apply node type filters
|
||||
if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) {
|
||||
continue;
|
||||
}
|
||||
if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue;
|
||||
if (contact.type != advTypeChat &&
|
||||
contact.type != advTypeRepeater &&
|
||||
!settings.mapShowOtherNodes) {
|
||||
continue;
|
||||
}
|
||||
nodeCount++;
|
||||
}
|
||||
final filteredContacts = _filterContactsBySettings(
|
||||
contacts,
|
||||
settings,
|
||||
noLocations: false,
|
||||
);
|
||||
final filteredContactsAll = _filterContactsBySettings(
|
||||
contacts,
|
||||
settings,
|
||||
noLocations: true,
|
||||
);
|
||||
|
||||
final nodeCount = filteredContacts.length;
|
||||
final nodeCountAll = filteredContactsAll.length;
|
||||
|
||||
return Positioned(
|
||||
top: 16,
|
||||
@@ -995,6 +1093,54 @@ class _MapScreenState extends State<MapScreen> {
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
": $nodeCount",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.wrong_location,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
": ${nodeCountAll - nodeCount}",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.add_outlined,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
": $nodeCountAll",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
context.l10n.map_pinsCount(markerCount),
|
||||
style: const TextStyle(
|
||||
@@ -1833,6 +1979,15 @@ class _MapScreenState extends State<MapScreen> {
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_showOverlaps),
|
||||
value: settings.mapShowOverlaps,
|
||||
onChanged: (value) {
|
||||
service.setMapShowOverlaps(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.map_keyPrefix,
|
||||
@@ -1982,26 +2137,35 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _addToPath(BuildContext context, Contact contact) {
|
||||
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
|
||||
setState(() {
|
||||
_pathTrace.add(
|
||||
contact.publicKey[0],
|
||||
); // Add first 16 bytes of public key to path trace
|
||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||
_pathTraceContacts.add(
|
||||
contact.copyWith(
|
||||
latitude: position?.latitude ?? contact.latitude,
|
||||
longitude: position?.longitude ?? contact.longitude,
|
||||
),
|
||||
); // Add contact to path trace contacts
|
||||
_points.add(position ?? LatLng(contact.latitude!, contact.longitude!));
|
||||
});
|
||||
}
|
||||
|
||||
void _startPath() {
|
||||
void _startPath(LatLng position) {
|
||||
setState(() {
|
||||
_isBuildingPathTrace = true;
|
||||
_pathTrace.clear();
|
||||
_pathTraceContacts.clear();
|
||||
_points.clear();
|
||||
_polylines.clear();
|
||||
_points.add(position);
|
||||
});
|
||||
}
|
||||
|
||||
void _removePath() {
|
||||
setState(() {
|
||||
_pathTraceContacts.removeLast();
|
||||
_pathTrace.removeLast(); // Remove last node from path trace
|
||||
_points.removeLast(); // Remove last point from points list
|
||||
_polylines.clear(); // Clear polylines
|
||||
@@ -2042,21 +2206,26 @@ class _MapScreenState extends State<MapScreen> {
|
||||
.join(','),
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// const SizedBox(height: 6),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
spacing: 1,
|
||||
runSpacing: 1,
|
||||
children: [
|
||||
if (_pathTrace.isNotEmpty)
|
||||
ElevatedButton(
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final hashW = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: l10n.contacts_pathTrace,
|
||||
path: Uint8List.fromList(_pathTrace),
|
||||
pathHashByteWidth: hashW,
|
||||
pathContacts: _pathTraceContacts,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2064,15 +2233,37 @@ class _MapScreenState extends State<MapScreen> {
|
||||
_isBuildingPathTrace = false;
|
||||
});
|
||||
},
|
||||
child: Text(l10n.map_runTrace),
|
||||
tooltip: l10n.map_runTrace,
|
||||
icon: const Icon(Icons.arrow_forward_outlined),
|
||||
),
|
||||
if (_pathTrace.isNotEmpty)
|
||||
ElevatedButton(
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: l10n.contacts_pathTrace,
|
||||
path: Uint8List.fromList(_pathTrace),
|
||||
flipPathAround: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_isBuildingPathTrace = false;
|
||||
});
|
||||
},
|
||||
tooltip: l10n.map_runTraceWithReturnPath,
|
||||
icon: const Icon(Icons.replay),
|
||||
),
|
||||
if (_pathTrace.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: _removePath,
|
||||
child: Text(l10n.map_removeLast),
|
||||
tooltip: l10n.map_removeLast,
|
||||
icon: const Icon(Icons.undo),
|
||||
),
|
||||
if (_pathTrace.isEmpty)
|
||||
ElevatedButton(
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isBuildingPathTrace = false;
|
||||
@@ -2084,7 +2275,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
SnackBar(content: Text(l10n.map_pathTraceCancelled)),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.common_cancel),
|
||||
tooltip: l10n.common_cancel,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -44,6 +44,24 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
PathSelection? _pendingStatusSelection;
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -124,7 +142,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
|
||||
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
|
||||
final buffer = BufferReader(frame);
|
||||
final contacts = connector.allContacts;
|
||||
final contacts = connector.allContactsUnfiltered;
|
||||
try {
|
||||
final neighborCount = buffer.readUInt16LE();
|
||||
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
||||
@@ -163,13 +181,6 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadNeighbors() async {
|
||||
if (_commandService == null) return;
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
final bool flipPathAround;
|
||||
final bool reversePathAround;
|
||||
final Contact? targetContact;
|
||||
final int pathHashByteWidth;
|
||||
final List<Contact>? pathContacts;
|
||||
|
||||
const PathTraceMapScreen({
|
||||
super.key,
|
||||
@@ -64,6 +66,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
this.flipPathAround = false,
|
||||
this.reversePathAround = false,
|
||||
this.targetContact,
|
||||
this.pathHashByteWidth = pathHashSize,
|
||||
this.pathContacts,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -72,6 +76,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
|
||||
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
static const double _labelZoomThreshold = 8.5;
|
||||
//miles to meters conversion for filtering out repeaters that are too far from the last known GPS hop to be a likely match, to avoid false matches that throw off the inferred positions of other hops in the path
|
||||
static const double _maxRepeaterMatchDistanceMeters = 40 * 1609.344;
|
||||
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
Timer? _timeoutTimer;
|
||||
@@ -119,8 +125,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
Uint8List traceBytes;
|
||||
|
||||
if (pathBytes.isEmpty) {
|
||||
final pk = widget.targetContact?.publicKey;
|
||||
final n = widget.pathHashByteWidth.clamp(1, pubKeySize);
|
||||
if (pk != null && pk.length >= n) {
|
||||
return Uint8List.fromList(pk.sublist(0, n));
|
||||
}
|
||||
traceBytes = Uint8List(1);
|
||||
traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
|
||||
traceBytes[0] = pk?[0] ?? 0;
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
@@ -259,17 +270,43 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
.toList();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
final contacts = connector.allContacts;
|
||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
for (var repeaterData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
Uint8List.fromList([repeaterData]),
|
||||
)) {
|
||||
pathContacts[repeaterData] = repeater;
|
||||
Contact lastContact = Contact(
|
||||
path: Uint8List(0),
|
||||
pathLength: 0,
|
||||
publicKey: connector.selfPublicKey ?? Uint8List(0),
|
||||
name: context.l10n.pathTrace_you,
|
||||
type: advTypeChat,
|
||||
latitude: connector.selfLatitude,
|
||||
longitude: connector.selfLongitude,
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
if (widget.pathContacts != null) {
|
||||
pathContacts = {for (var c in widget.pathContacts!) c.publicKey[0]: c};
|
||||
} else {
|
||||
final contacts = connector.allContactsUnfiltered;
|
||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
if (lastContact.latitude != null &&
|
||||
lastContact.longitude != null &&
|
||||
repeater.hasLocation &&
|
||||
lastContact.hasLocation &&
|
||||
Distance().distance(
|
||||
LatLng(lastContact.latitude!, lastContact.longitude!),
|
||||
LatLng(repeater.latitude!, repeater.longitude!),
|
||||
) >
|
||||
_maxRepeaterMatchDistanceMeters) {
|
||||
return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches
|
||||
}
|
||||
}
|
||||
});
|
||||
for (var repeaterData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
Uint8List.fromList([repeaterData]),
|
||||
)) {
|
||||
pathContacts[repeaterData] = repeater;
|
||||
lastContact = repeater;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For hops with no GPS contact, infer position from other contacts
|
||||
// with known GPS that share the same last-hop byte.
|
||||
|
||||
@@ -35,13 +35,15 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
// Common commands for quick access
|
||||
late final List<Map<String, String>> _quickCommands = [
|
||||
{'labelKey': 'advertise', 'command': 'advert'},
|
||||
{'labelKey': 'getName', 'command': 'get name'},
|
||||
{'labelKey': 'getRadio', 'command': 'get radio'},
|
||||
{'labelKey': 'getTx', 'command': 'get tx'},
|
||||
{'labelKey': 'discovery', 'command': 'discover.neighbors'},
|
||||
{'labelKey': 'neighbors', 'command': 'neighbors'},
|
||||
{'labelKey': 'version', 'command': 'ver'},
|
||||
{'labelKey': 'advertise', 'command': 'advert'},
|
||||
{'labelKey': 'clock', 'command': 'clock'},
|
||||
{'labelKey': 'clock sync', 'command': 'clock sync'},
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -77,11 +79,22 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
int _resolveRepeaterIndex = -1;
|
||||
|
||||
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,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
if (_resolveRepeaterIndex == -1) {
|
||||
return widget.repeater;
|
||||
}
|
||||
return connector.contacts[_resolveRepeaterIndex];
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
@@ -396,6 +409,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
return l10n.repeater_cliQuickAdvertise;
|
||||
case 'clock':
|
||||
return l10n.repeater_cliQuickClock;
|
||||
case 'clock sync':
|
||||
return l10n.repeater_cliQuickClockSync;
|
||||
case 'discovery':
|
||||
return l10n.repeater_cliQuickDiscovery;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -205,8 +205,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
TelemetryScreen(repeater: repeater, password: password),
|
||||
builder: (context) => TelemetryScreen(contact: repeater),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -129,11 +129,22 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||
}
|
||||
|
||||
int _resolveRepeaterIndex = -1;
|
||||
|
||||
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,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
if (_resolveRepeaterIndex == -1) {
|
||||
return widget.repeater;
|
||||
}
|
||||
return connector.contacts[_resolveRepeaterIndex];
|
||||
}
|
||||
|
||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||
|
||||
@@ -91,11 +91,22 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
int _resolveRepeaterIndex = -1;
|
||||
|
||||
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,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
if (_resolveRepeaterIndex == -1) {
|
||||
return widget.repeater;
|
||||
}
|
||||
return connector.contacts[_resolveRepeaterIndex];
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/linux_ble_error_classifier.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
@@ -288,12 +289,33 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
MeshCoreConnector connector,
|
||||
ScanResult result,
|
||||
) async {
|
||||
final name = result.device.platformName.isNotEmpty
|
||||
? result.device.platformName
|
||||
: result.advertisementData.advName;
|
||||
try {
|
||||
final name = result.device.platformName.isNotEmpty
|
||||
? result.device.platformName
|
||||
: result.advertisementData.advName;
|
||||
await connector.connect(result.device, displayName: name);
|
||||
await connector.connect(
|
||||
result.device,
|
||||
displayName: name,
|
||||
linuxPairingPinProvider: PlatformInfo.isLinux
|
||||
? () async {
|
||||
if (!context.mounted) return null;
|
||||
return _promptLinuxPairingPin(context, name);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
} catch (e) {
|
||||
final errorText = e.toString();
|
||||
final suppressTransientLinuxConnectError =
|
||||
PlatformInfo.isLinux &&
|
||||
connector.isAutoReconnectScheduled &&
|
||||
isLinuxBleConnectFailureText(errorText);
|
||||
if (suppressTransientLinuxConnectError) {
|
||||
appLogger.info(
|
||||
'Suppressing transient Linux connect error while auto-reconnect is active: $e',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -305,6 +327,92 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptLinuxPairingPin(
|
||||
BuildContext context,
|
||||
String deviceName,
|
||||
) async {
|
||||
final l10n = context.l10n;
|
||||
var pinValue = '';
|
||||
var obscure = true;
|
||||
appLogger.info(
|
||||
'Showing Linux BLE pairing PIN prompt for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
final pin = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.scanner_linuxPairingPinTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.scanner_linuxPairingPinPrompt(deviceName)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
obscureText: obscure,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onChanged: (value) {
|
||||
pinValue = value.trim();
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
Navigator.of(dialogContext).pop(value.trim());
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
setDialogState(() {
|
||||
obscure = !obscure;
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
obscure ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
tooltip: obscure
|
||||
? l10n.scanner_linuxPairingShowPin
|
||||
: l10n.scanner_linuxPairingHidePin,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(null),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(pinValue),
|
||||
child: Text(l10n.common_connect),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (pin == null) {
|
||||
appLogger.info(
|
||||
'Linux BLE pairing PIN prompt cancelled for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
appLogger.info(
|
||||
'Linux BLE pairing PIN prompt completed for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return pin;
|
||||
}
|
||||
|
||||
Widget _bluetoothOffWarning(BuildContext context) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
return Container(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/utils/gpx_export.dart';
|
||||
import 'package:meshcore_open/widgets/elements_ui.dart';
|
||||
@@ -8,10 +9,27 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/radio_settings.dart';
|
||||
import '../services/app_debug_log_service.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import 'app_settings_screen.dart';
|
||||
import 'app_debug_log_screen.dart';
|
||||
import 'ble_debug_log_screen.dart';
|
||||
import '../widgets/radio_stats_entry.dart';
|
||||
|
||||
/// Convert device coding-rate value (1-4 on some firmware, 5-8 on others)
|
||||
/// to the UI enum range (always 5-8).
|
||||
int _toUiCodingRate(int deviceCr) {
|
||||
return deviceCr <= 4 ? deviceCr + 4 : deviceCr;
|
||||
}
|
||||
|
||||
/// Convert UI coding-rate value (5-8) back to firmware encoding.
|
||||
/// Uses the current device CR to detect which encoding the firmware expects.
|
||||
int _toDeviceCodingRate(int uiCr, int? deviceCr) {
|
||||
if (deviceCr != null && deviceCr <= 4) {
|
||||
return uiCr - 4;
|
||||
}
|
||||
return uiCr;
|
||||
}
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -269,6 +287,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onTap: () => _showRadioSettings(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sensors_outlined),
|
||||
title: Text(l10n.radioStats_settingsTile),
|
||||
subtitle: Text(l10n.radioStats_settingsSubtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
enabled:
|
||||
connector.isConnected && connector.supportsCompanionRadioStats,
|
||||
onTap: () => pushCompanionRadioStatsScreen(context),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.location_on_outlined),
|
||||
title: Text(l10n.settings_location),
|
||||
@@ -287,10 +315,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.visibility_off_outlined),
|
||||
title: Text(l10n.settings_privacyMode),
|
||||
subtitle: Text(l10n.settings_privacyModeSubtitle),
|
||||
title: Text(l10n.settings_privacy),
|
||||
subtitle: Text(l10n.settings_privacySubtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _togglePrivacy(context, connector),
|
||||
onTap: () => _privacySettings(context, connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -311,10 +339,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower),
|
||||
title: Text(l10n.settings_sendAdvertisement),
|
||||
subtitle: Text(l10n.settings_sendAdvertisementSubtitle),
|
||||
onTap: () => _sendAdvert(context, connector),
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: Text("Delete All Paths"),
|
||||
subtitle: Text(
|
||||
"Clear all path data from contacts.",
|
||||
style: TextStyle(color: Colors.red[700]),
|
||||
),
|
||||
onTap: () => connector.deleteAllPaths(),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
@@ -657,55 +688,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) {
|
||||
final l10n = context.l10n;
|
||||
connector.sendSelfAdvert(flood: true);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.settings_advertisementSent)));
|
||||
}
|
||||
|
||||
void _syncTime(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
connector.syncTime();
|
||||
@@ -977,6 +959,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 {
|
||||
final MeshCoreConnector connector;
|
||||
|
||||
@@ -993,6 +1105,11 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5;
|
||||
final _txPowerController = TextEditingController(text: '20');
|
||||
bool _clientRepeat = false;
|
||||
int? _selectedPresetIndex;
|
||||
_RadioSettingsSnapshot? _lastNonRepeatSnapshot;
|
||||
|
||||
AppDebugLogService get _appLog =>
|
||||
Provider.of<AppDebugLogService>(context, listen: false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -1044,6 +1161,21 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
}
|
||||
|
||||
_clientRepeat = widget.connector.clientRepeat ?? false;
|
||||
_selectedPresetIndex = _findMatchingPresetIndex();
|
||||
if (_clientRepeat) {
|
||||
_lastNonRepeatSnapshot =
|
||||
_sessionRememberedNonRepeatSnapshot() ??
|
||||
_inferNonRepeatSnapshotForRepeatEnabled();
|
||||
_selectedPresetIndex = _findMatchingPresetIndexForSnapshot(
|
||||
_lastNonRepeatSnapshot!,
|
||||
);
|
||||
} else {
|
||||
_lastNonRepeatSnapshot = _nonRepeatSnapshotForCurrentSelection();
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_logRadioSettingsState('Dialog initialized');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1053,14 +1185,223 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _applyPreset(RadioSettings preset) {
|
||||
void _applyPreset(int index) {
|
||||
setState(() {
|
||||
_frequencyController.text = preset.frequencyMHz.toString();
|
||||
_bandwidth = preset.bandwidth;
|
||||
_spreadingFactor = preset.spreadingFactor;
|
||||
_codingRate = preset.codingRate;
|
||||
_txPowerController.text = preset.txPowerDbm.toString();
|
||||
_applyPresetState(index);
|
||||
});
|
||||
_logRadioSettingsState(
|
||||
'Applied preset ${RadioSettings.presets[index].$1} (#$index)',
|
||||
);
|
||||
}
|
||||
|
||||
int? _findMatchingPresetIndex() {
|
||||
return _findMatchingPresetIndexForSnapshot(_currentSnapshot());
|
||||
}
|
||||
|
||||
int? _findMatchingPresetIndexForSnapshot(_RadioSettingsSnapshot snapshot) {
|
||||
for (final i in _visiblePresetIndexes()) {
|
||||
final preset = RadioSettings.presets[i].$2;
|
||||
if (preset.frequencyHz == snapshot.frequencyHz &&
|
||||
preset.bandwidth == snapshot.bandwidth &&
|
||||
preset.spreadingFactor == snapshot.spreadingFactor &&
|
||||
preset.codingRate == snapshot.codingRate &&
|
||||
preset.txPowerDbm == snapshot.txPowerDbm) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Iterable<int> _visiblePresetIndexes() sync* {
|
||||
for (var i = 0; i < RadioSettings.presets.length; i++) {
|
||||
if (_isOffGridPresetIndex(i)) {
|
||||
continue;
|
||||
}
|
||||
yield i;
|
||||
}
|
||||
}
|
||||
|
||||
_RadioSettingsSnapshot _currentSnapshot() {
|
||||
final frequencyMHz = double.tryParse(_frequencyController.text) ?? 915.0;
|
||||
final txPowerDbm = int.tryParse(_txPowerController.text) ?? 20;
|
||||
return _RadioSettingsSnapshot(
|
||||
frequencyMHz: frequencyMHz,
|
||||
bandwidth: _bandwidth,
|
||||
spreadingFactor: _spreadingFactor,
|
||||
codingRate: _codingRate,
|
||||
txPowerDbm: txPowerDbm,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isOffGridPresetIndex(int? index) {
|
||||
if (index == null) return false;
|
||||
return RadioSettings.presets[index].$1.startsWith('Off-Grid ');
|
||||
}
|
||||
|
||||
double _offGridFrequencyForBaseFrequency(double baseFrequencyMHz) {
|
||||
if (baseFrequencyMHz < 500) return 433.0;
|
||||
if (baseFrequencyMHz < 900) return 869.0;
|
||||
return 918.0;
|
||||
}
|
||||
|
||||
double _normalFrequencyForBand(double frequencyMHz) {
|
||||
if (frequencyMHz < 500) return 433.650;
|
||||
if (frequencyMHz < 900) return 869.432;
|
||||
return 915.8;
|
||||
}
|
||||
|
||||
_RadioSettingsSnapshot _fallbackNonRepeatSnapshot(
|
||||
double currentFrequencyMHz,
|
||||
) {
|
||||
return _RadioSettingsSnapshot(
|
||||
frequencyMHz: _normalFrequencyForBand(currentFrequencyMHz),
|
||||
bandwidth: _bandwidth,
|
||||
spreadingFactor: _spreadingFactor,
|
||||
codingRate: _codingRate,
|
||||
txPowerDbm: int.tryParse(_txPowerController.text) ?? 20,
|
||||
);
|
||||
}
|
||||
|
||||
_RadioSettingsSnapshot _nonRepeatSnapshotForCurrentSelection() {
|
||||
final current = _currentSnapshot();
|
||||
if (!_isOffGridPresetIndex(_selectedPresetIndex)) {
|
||||
return current;
|
||||
}
|
||||
return _fallbackNonRepeatSnapshot(current.frequencyMHz);
|
||||
}
|
||||
|
||||
_RadioSettingsSnapshot? _sessionRememberedNonRepeatSnapshot() {
|
||||
final snapshot = widget.connector.rememberedNonRepeatRadioState;
|
||||
if (snapshot == null) return null;
|
||||
return _RadioSettingsSnapshot.fromMeshCoreSnapshot(snapshot);
|
||||
}
|
||||
|
||||
_RadioSettingsSnapshot _inferNonRepeatSnapshotForRepeatEnabled() {
|
||||
final current = _currentSnapshot();
|
||||
for (final i in _visiblePresetIndexes()) {
|
||||
final preset = RadioSettings.presets[i].$2;
|
||||
final offGridFreqHz =
|
||||
(_offGridFrequencyForBaseFrequency(preset.frequencyMHz) * 1000)
|
||||
.round();
|
||||
if (offGridFreqHz == current.frequencyHz &&
|
||||
preset.bandwidth == current.bandwidth &&
|
||||
preset.spreadingFactor == current.spreadingFactor &&
|
||||
preset.codingRate == current.codingRate &&
|
||||
preset.txPowerDbm == current.txPowerDbm) {
|
||||
return _RadioSettingsSnapshot(
|
||||
frequencyMHz: preset.frequencyMHz,
|
||||
bandwidth: preset.bandwidth,
|
||||
spreadingFactor: preset.spreadingFactor,
|
||||
codingRate: preset.codingRate,
|
||||
txPowerDbm: preset.txPowerDbm,
|
||||
);
|
||||
}
|
||||
}
|
||||
return _fallbackNonRepeatSnapshot(current.frequencyMHz);
|
||||
}
|
||||
|
||||
void _applySnapshot(_RadioSettingsSnapshot snapshot) {
|
||||
_frequencyController.text = snapshot.frequencyMHz.toStringAsFixed(3);
|
||||
_bandwidth = snapshot.bandwidth;
|
||||
_spreadingFactor = snapshot.spreadingFactor;
|
||||
_codingRate = snapshot.codingRate;
|
||||
_txPowerController.text = snapshot.txPowerDbm.toString();
|
||||
}
|
||||
|
||||
void _applyPresetState(int index) {
|
||||
final preset = RadioSettings.presets[index].$2;
|
||||
final baseSnapshot = _RadioSettingsSnapshot(
|
||||
frequencyMHz: preset.frequencyMHz,
|
||||
bandwidth: preset.bandwidth,
|
||||
spreadingFactor: preset.spreadingFactor,
|
||||
codingRate: preset.codingRate,
|
||||
txPowerDbm: preset.txPowerDbm,
|
||||
);
|
||||
final frequencyMHz = _clientRepeat
|
||||
? _offGridFrequencyForBaseFrequency(baseSnapshot.frequencyMHz)
|
||||
: baseSnapshot.frequencyMHz;
|
||||
_frequencyController.text = frequencyMHz.toString();
|
||||
_bandwidth = preset.bandwidth;
|
||||
_spreadingFactor = preset.spreadingFactor;
|
||||
_codingRate = preset.codingRate;
|
||||
_txPowerController.text = preset.txPowerDbm.toString();
|
||||
_selectedPresetIndex = index;
|
||||
_lastNonRepeatSnapshot = baseSnapshot;
|
||||
}
|
||||
|
||||
void _syncPresetSelection() {
|
||||
final previousPresetIndex = _selectedPresetIndex;
|
||||
final previousLastNonRepeat = _lastNonRepeatSnapshot;
|
||||
if (_clientRepeat) {
|
||||
final baseSnapshot =
|
||||
previousLastNonRepeat ?? _inferNonRepeatSnapshotForRepeatEnabled();
|
||||
if (_bandwidth != baseSnapshot.bandwidth ||
|
||||
_spreadingFactor != baseSnapshot.spreadingFactor ||
|
||||
_codingRate != baseSnapshot.codingRate ||
|
||||
(int.tryParse(_txPowerController.text) ?? 20) !=
|
||||
baseSnapshot.txPowerDbm) {
|
||||
_lastNonRepeatSnapshot = _RadioSettingsSnapshot(
|
||||
frequencyMHz: baseSnapshot.frequencyMHz,
|
||||
bandwidth: _bandwidth,
|
||||
spreadingFactor: _spreadingFactor,
|
||||
codingRate: _codingRate,
|
||||
txPowerDbm: int.tryParse(_txPowerController.text) ?? 20,
|
||||
);
|
||||
}
|
||||
_selectedPresetIndex = _findMatchingPresetIndexForSnapshot(
|
||||
_lastNonRepeatSnapshot ?? baseSnapshot,
|
||||
);
|
||||
if (previousPresetIndex != _selectedPresetIndex ||
|
||||
previousLastNonRepeat != _lastNonRepeatSnapshot) {
|
||||
_logRadioSettingsState(
|
||||
'Preset match updated while repeat enabled: ${_presetLabel(previousPresetIndex)} -> ${_presetLabel(_selectedPresetIndex)}',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_lastNonRepeatSnapshot = _nonRepeatSnapshotForCurrentSelection();
|
||||
_selectedPresetIndex = _findMatchingPresetIndexForSnapshot(
|
||||
_lastNonRepeatSnapshot!,
|
||||
);
|
||||
if (previousPresetIndex != _selectedPresetIndex ||
|
||||
previousLastNonRepeat != _lastNonRepeatSnapshot) {
|
||||
_logRadioSettingsState(
|
||||
'Preset sync updated state from ${_presetLabel(previousPresetIndex)} to ${_presetLabel(_selectedPresetIndex)}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleManualSettingsChanged(String source) {
|
||||
_logRadioSettingsState('Manual settings edit: $source');
|
||||
setState(_syncPresetSelection);
|
||||
}
|
||||
|
||||
void _handleClientRepeatChanged(bool enabled) {
|
||||
_logRadioSettingsState(
|
||||
'Off-grid repeat toggle requested: $_clientRepeat -> $enabled',
|
||||
);
|
||||
setState(() {
|
||||
final currentSnapshot = _currentSnapshot();
|
||||
if (enabled) {
|
||||
if (!_clientRepeat) {
|
||||
_syncPresetSelection();
|
||||
}
|
||||
final baseSnapshot = _lastNonRepeatSnapshot ?? currentSnapshot;
|
||||
_clientRepeat = true;
|
||||
_frequencyController.text = _offGridFrequencyForBaseFrequency(
|
||||
baseSnapshot.frequencyMHz,
|
||||
).toStringAsFixed(3);
|
||||
return;
|
||||
}
|
||||
|
||||
_clientRepeat = false;
|
||||
_applySnapshot(
|
||||
_lastNonRepeatSnapshot ??
|
||||
_fallbackNonRepeatSnapshot(currentSnapshot.frequencyMHz),
|
||||
);
|
||||
_syncPresetSelection();
|
||||
});
|
||||
_logRadioSettingsState('Off-grid repeat toggle applied');
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
@@ -1108,6 +1449,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
}
|
||||
|
||||
try {
|
||||
_logRadioSettingsState('Saving radio settings');
|
||||
await widget.connector.sendFrame(
|
||||
buildSetRadioParamsFrame(
|
||||
freqHz,
|
||||
@@ -1119,29 +1461,62 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
);
|
||||
await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower));
|
||||
await widget.connector.refreshDeviceInfo();
|
||||
final rememberedSnapshot = _clientRepeat
|
||||
? _lastNonRepeatSnapshot
|
||||
: _currentSnapshot();
|
||||
if (rememberedSnapshot != null) {
|
||||
widget.connector.rememberNonRepeatRadioState(
|
||||
rememberedSnapshot.toMeshCoreSnapshot(widget.connector.currentCr),
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
_logRadioSettingsState('Radio settings saved successfully');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.settings_radioSettingsUpdated)),
|
||||
);
|
||||
} catch (e) {
|
||||
_appLog.warn('Radio settings save failed: $e', tag: 'RadioSettings');
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.settings_error(e.toString()))),
|
||||
);
|
||||
}
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
int _toUiCodingRate(int deviceCr) {
|
||||
return deviceCr <= 4 ? deviceCr + 4 : deviceCr;
|
||||
}
|
||||
|
||||
int _toDeviceCodingRate(int uiCr, int? deviceCr) {
|
||||
if (deviceCr != null && deviceCr <= 4) {
|
||||
return uiCr - 4;
|
||||
String _presetLabel(int? index) {
|
||||
if (index == null) {
|
||||
return 'custom';
|
||||
}
|
||||
return uiCr;
|
||||
return '${RadioSettings.presets[index].$1} (#$index)';
|
||||
}
|
||||
|
||||
String _formatSnapshot(_RadioSettingsSnapshot? snapshot) {
|
||||
if (snapshot == null) {
|
||||
return 'null';
|
||||
}
|
||||
return '${snapshot.frequencyMHz.toStringAsFixed(3)}MHz/'
|
||||
'${snapshot.bandwidth.label}/'
|
||||
'${snapshot.spreadingFactor.label}/'
|
||||
'${snapshot.codingRate.label}/'
|
||||
'${snapshot.txPowerDbm}dBm';
|
||||
}
|
||||
|
||||
void _logRadioSettingsState(String message) {
|
||||
if (!kDebugMode) return;
|
||||
_appLog.info(
|
||||
'$message | '
|
||||
'freq=${_frequencyController.text}MHz '
|
||||
'bw=${_bandwidth.label} '
|
||||
'sf=${_spreadingFactor.label} '
|
||||
'cr=${_codingRate.label} '
|
||||
'tx=${_txPowerController.text}dBm '
|
||||
'repeat=$_clientRepeat '
|
||||
'preset=${_presetLabel(_selectedPresetIndex)} '
|
||||
'lastNonRepeat=${_formatSnapshot(_lastNonRepeatSnapshot)}',
|
||||
tag: 'RadioSettings',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1155,12 +1530,14 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
key: ValueKey<int?>(_selectedPresetIndex),
|
||||
initialValue: _selectedPresetIndex,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.settings_presets,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (var i = 0; i < RadioSettings.presets.length; i++)
|
||||
for (final i in _visiblePresetIndexes())
|
||||
DropdownMenuItem(
|
||||
value: i,
|
||||
child: Text(RadioSettings.presets[i].$1),
|
||||
@@ -1168,13 +1545,14 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
],
|
||||
onChanged: (index) {
|
||||
if (index != null) {
|
||||
_applyPreset(RadioSettings.presets[index].$2);
|
||||
_applyPreset(index);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _frequencyController,
|
||||
onChanged: (_) => _handleManualSettingsChanged('frequency'),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.settings_frequency,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -1197,7 +1575,13 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _bandwidth = value);
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_bandwidth = value;
|
||||
_syncPresetSelection();
|
||||
});
|
||||
_logRadioSettingsState('Manual settings edit: bandwidth');
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -1213,7 +1597,15 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _spreadingFactor = value);
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_spreadingFactor = value;
|
||||
_syncPresetSelection();
|
||||
});
|
||||
_logRadioSettingsState(
|
||||
'Manual settings edit: spreading factor',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -1229,12 +1621,19 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _codingRate = value);
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_codingRate = value;
|
||||
_syncPresetSelection();
|
||||
});
|
||||
_logRadioSettingsState('Manual settings edit: coding rate');
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _txPowerController,
|
||||
onChanged: (_) => _handleManualSettingsChanged('tx power'),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.settings_txPower,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -1250,7 +1649,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
title: Text(l10n.settings_clientRepeat),
|
||||
subtitle: Text(l10n.settings_clientRepeatSubtitle),
|
||||
value: _clientRepeat,
|
||||
onChanged: (value) => setState(() => _clientRepeat = value),
|
||||
onChanged: _handleClientRepeatChanged,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
@@ -1267,3 +1666,75 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RadioSettingsSnapshot {
|
||||
final double frequencyMHz;
|
||||
final LoRaBandwidth bandwidth;
|
||||
final LoRaSpreadingFactor spreadingFactor;
|
||||
final LoRaCodingRate codingRate;
|
||||
final int txPowerDbm;
|
||||
|
||||
const _RadioSettingsSnapshot({
|
||||
required this.frequencyMHz,
|
||||
required this.bandwidth,
|
||||
required this.spreadingFactor,
|
||||
required this.codingRate,
|
||||
required this.txPowerDbm,
|
||||
});
|
||||
|
||||
/// Frequency in integer Hz — avoids floating-point comparison issues.
|
||||
int get frequencyHz => (frequencyMHz * 1000).round();
|
||||
|
||||
/// Convert from the connector's raw-int snapshot to UI-enum snapshot.
|
||||
static _RadioSettingsSnapshot? fromMeshCoreSnapshot(
|
||||
MeshCoreRadioStateSnapshot snapshot,
|
||||
) {
|
||||
final bw = LoRaBandwidth.values
|
||||
.where((b) => b.hz == snapshot.bwHz)
|
||||
.firstOrNull;
|
||||
final sf = LoRaSpreadingFactor.values
|
||||
.where((s) => s.value == snapshot.sf)
|
||||
.firstOrNull;
|
||||
final cr = LoRaCodingRate.values
|
||||
.where((c) => c.value == _toUiCodingRate(snapshot.cr))
|
||||
.firstOrNull;
|
||||
if (bw == null || sf == null || cr == null) return null;
|
||||
return _RadioSettingsSnapshot(
|
||||
frequencyMHz: snapshot.freqHz / 1000.0,
|
||||
bandwidth: bw,
|
||||
spreadingFactor: sf,
|
||||
codingRate: cr,
|
||||
txPowerDbm: snapshot.txPowerDbm,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert back to the connector's raw-int snapshot.
|
||||
MeshCoreRadioStateSnapshot toMeshCoreSnapshot(int? deviceCr) {
|
||||
return MeshCoreRadioStateSnapshot(
|
||||
freqHz: frequencyHz,
|
||||
bwHz: bandwidth.hz,
|
||||
sf: spreadingFactor.value,
|
||||
cr: _toDeviceCodingRate(codingRate.value, deviceCr),
|
||||
txPowerDbm: txPowerDbm,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is _RadioSettingsSnapshot &&
|
||||
frequencyHz == other.frequencyHz &&
|
||||
bandwidth == other.bandwidth &&
|
||||
spreadingFactor == other.spreadingFactor &&
|
||||
codingRate == other.codingRate &&
|
||||
txPowerDbm == other.txPowerDbm;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
frequencyHz,
|
||||
bandwidth,
|
||||
spreadingFactor,
|
||||
codingRate,
|
||||
txPowerDbm,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,30 +10,22 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
import '../helpers/cayenne_lpp.dart';
|
||||
import '../utils/battery_utils.dart';
|
||||
|
||||
class TelemetryScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
final String password;
|
||||
final Contact contact;
|
||||
|
||||
const TelemetryScreen({
|
||||
super.key,
|
||||
required this.repeater,
|
||||
required this.password,
|
||||
});
|
||||
const TelemetryScreen({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
State<TelemetryScreen> createState() => _TelemetryScreenState();
|
||||
}
|
||||
|
||||
class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
static const int _statusPayloadOffset = 8;
|
||||
static const int _statusStatsSize = 52;
|
||||
static const int _statusResponseBytes =
|
||||
_statusPayloadOffset + _statusStatsSize;
|
||||
Uint8List _tagData = Uint8List(4);
|
||||
int _tagData = 0;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isLoaded = false;
|
||||
@@ -44,6 +36,26 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
PathSelection? _pendingStatusSelection;
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -60,27 +72,62 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
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) {
|
||||
_tagData = frame.sublist(2, 6);
|
||||
}
|
||||
// Check if it's a binary response
|
||||
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
|
||||
if (frame[0] == pushCodeBinaryResponse &&
|
||||
listEquals(frame.sublist(2, 6), _tagData)) {
|
||||
if (!mounted) return;
|
||||
_handleStatusResponse(frame.sublist(6));
|
||||
// Check if it's a telemetry response (for chat contacts)
|
||||
if (cmd == pushCodeTelemetryResponse) {
|
||||
reader.skipBytes(1); // Skip the reserved byte
|
||||
final pubkey = reader.readBytes(6);
|
||||
if (!mounted) return;
|
||||
if (!listEquals(widget.contact.publicKey.sublist(0, 6), pubkey)) {
|
||||
return;
|
||||
}
|
||||
_handleTelemetryResponse(reader.readRemainingBytes());
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.error('Error parsing incoming frame: $e');
|
||||
// If parsing fails, ignore the frame
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleStatusResponse(Uint8List frame) {
|
||||
void _handleTelemetryResponse(Uint8List frame) {
|
||||
final parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
||||
final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry);
|
||||
if (batteryMv != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
connector.updateRepeaterBatterySnapshot(
|
||||
widget.repeater.publicKeyHex,
|
||||
widget.contact.publicKeyHex,
|
||||
batteryMv,
|
||||
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 {
|
||||
if (_commandService == null) return;
|
||||
|
||||
@@ -121,41 +161,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
});
|
||||
try {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final selection = await connector.preparePathForContactSend(repeater);
|
||||
final selection = await connector.preparePathForContactSend(
|
||||
_resolveContact(connector),
|
||||
);
|
||||
_pendingStatusSelection = selection;
|
||||
final frame = buildSendBinaryReq(
|
||||
repeater.publicKey,
|
||||
payload: Uint8List.fromList([reqTypeGetTelemetry]),
|
||||
);
|
||||
await connector.sendFrame(frame);
|
||||
|
||||
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,
|
||||
),
|
||||
Uint8List frame;
|
||||
if (widget.contact.type != advTypeChat) {
|
||||
frame = buildSendBinaryReq(
|
||||
widget.contact.publicKey,
|
||||
payload: Uint8List.fromList([reqTypeGetTelemetry]),
|
||||
);
|
||||
_recordStatusResult(false);
|
||||
});
|
||||
} else {
|
||||
frame = buildSendTelemetryReq(widget.contact.publicKey);
|
||||
}
|
||||
await connector.sendFrame(frame);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -173,12 +192,16 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _recordStatusResult(bool success) {
|
||||
void _recordTelemetryResult(bool success) {
|
||||
final selection = _pendingStatusSelection;
|
||||
if (selection == null) return;
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
||||
connector.recordRepeaterPathResult(
|
||||
widget.contact,
|
||||
selection,
|
||||
success,
|
||||
null,
|
||||
);
|
||||
_pendingStatusSelection = null;
|
||||
}
|
||||
|
||||
@@ -196,8 +219,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final settings = context.watch<AppSettingsService>().settings;
|
||||
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
final isFloodMode = widget.contact.pathOverride == -1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -210,7 +232,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
widget.contact.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
@@ -225,9 +247,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
tooltip: l10n.repeater_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
await connector.setPathOverride(widget.contact, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
await connector.setPathOverride(widget.contact, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
@@ -283,7 +305,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
PathManagementDialog.show(context, contact: widget.contact),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
@@ -437,7 +459,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final batteryMv =
|
||||
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
|
||||
connector.getRepeaterBatteryMillivolts(widget.contact.publicKeyHex) ??
|
||||
(telemetryVolts == null ? null : (telemetryVolts * 1000).round());
|
||||
if (batteryMv == null) return l10n.common_notAvailable;
|
||||
final chemistry = _batteryChemistry();
|
||||
@@ -449,7 +471,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
String _batteryChemistry() {
|
||||
final settingsService = context.read<AppSettingsService>();
|
||||
return settingsService.batteryChemistryForRepeater(
|
||||
widget.repeater.publicKeyHex,
|
||||
widget.contact.publicKeyHex,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/translation_support.dart';
|
||||
import '../storage/prefs_manager.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
@@ -64,6 +65,10 @@ class AppSettingsService extends ChangeNotifier {
|
||||
await updateSettings(_settings.copyWith(mapShowOtherNodes: value));
|
||||
}
|
||||
|
||||
Future<void> setMapShowOverlaps(bool value) async {
|
||||
await updateSettings(_settings.copyWith(mapShowOverlaps: value));
|
||||
}
|
||||
|
||||
Future<void> setMapTimeFilterHours(double value) async {
|
||||
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
|
||||
}
|
||||
@@ -120,6 +125,30 @@ class AppSettingsService extends ChangeNotifier {
|
||||
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 {
|
||||
await updateSettings(_settings.copyWith(themeMode: value));
|
||||
}
|
||||
@@ -190,4 +219,38 @@ class AppSettingsService extends ChangeNotifier {
|
||||
Future<void> setTcpServerPort(int value) async {
|
||||
await updateSettings(_settings.copyWith(tcpServerPort: value));
|
||||
}
|
||||
|
||||
Future<void> setJumpToOldestUnread(bool value) async {
|
||||
await updateSettings(_settings.copyWith(jumpToOldestUnread: value));
|
||||
}
|
||||
|
||||
Future<void> setTranslationEnabled(bool value) async {
|
||||
await updateSettings(_settings.copyWith(translationEnabled: value));
|
||||
}
|
||||
|
||||
Future<void> setTranslationTargetLanguageCode(String? value) async {
|
||||
await updateSettings(
|
||||
_settings.copyWith(translationTargetLanguageCode: value),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setComposerTranslationEnabled(bool value) async {
|
||||
await updateSettings(_settings.copyWith(composerTranslationEnabled: value));
|
||||
}
|
||||
|
||||
Future<void> setTranslationModelSourceUrl(String? value) async {
|
||||
await updateSettings(_settings.copyWith(translationModelSourceUrl: value));
|
||||
}
|
||||
|
||||
Future<void> setTranslationSelectedModelId(String? value) async {
|
||||
await updateSettings(_settings.copyWith(translationSelectedModelId: value));
|
||||
}
|
||||
|
||||
Future<void> setTranslationDownloadedModels(
|
||||
List<TranslationModelRecord> value,
|
||||
) async {
|
||||
await updateSettings(
|
||||
_settings.copyWith(translationDownloadedModels: value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const String linuxConnectStageFailureMarker = 'linux connect stage failure';
|
||||
|
||||
bool isLinuxBleConnectFailureText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
if (isLinuxBlePairingFailureText(errorText)) {
|
||||
return false;
|
||||
}
|
||||
return lowerErrorText.contains(linuxConnectStageFailureMarker) ||
|
||||
lowerErrorText.contains('| connect |') ||
|
||||
lowerErrorText.contains('linux connect hard-timeout') ||
|
||||
lowerErrorText.contains('org.bluez.error.failed') ||
|
||||
lowerErrorText.contains('org.bluez.error.inprogress') ||
|
||||
lowerErrorText.contains('le-connection-abort-by-local');
|
||||
}
|
||||
|
||||
bool isLinuxBlePairingFailureText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
final isPairingSpecificStateError =
|
||||
lowerErrorText.contains('bad state: no element') &&
|
||||
(lowerErrorText.contains('pair') ||
|
||||
lowerErrorText.contains('bond') ||
|
||||
lowerErrorText.contains('trust'));
|
||||
return lowerErrorText.contains('authenticationfailed') ||
|
||||
lowerErrorText.contains('authentication failed') ||
|
||||
lowerErrorText.contains('notpermitted: not paired') ||
|
||||
lowerErrorText.contains('pairing fallback failed') ||
|
||||
lowerErrorText.contains('linux ble pairing did not complete') ||
|
||||
lowerErrorText.contains('linux ble trust repair did not complete') ||
|
||||
isPairingSpecificStateError ||
|
||||
isLikelyLinuxBlePairingTimeoutText(errorText);
|
||||
}
|
||||
|
||||
bool isLikelyLinuxBlePairingTimeoutText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
return lowerErrorText.contains('timed out') &&
|
||||
(lowerErrorText.contains('pair') || lowerErrorText.contains('bond'));
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
typedef ProcessStartFn =
|
||||
Future<Process> Function(String executable, List<String> arguments);
|
||||
typedef ProcessRunFn =
|
||||
Future<ProcessResult> Function(String executable, List<String> arguments);
|
||||
|
||||
/// Best-effort Linux BLE pairing helper using bluetoothctl.
|
||||
///
|
||||
/// This is used only as a fallback when BlueZ pairing via flutter_blue_plus
|
||||
/// fails to surface agent prompts in-app.
|
||||
class LinuxBlePairingService {
|
||||
/// Maximum number of pairing attempts (initial + retries).
|
||||
/// Covers one remove-and-retry plus one proactive-PIN retry.
|
||||
static const int _maxAttempts = 3;
|
||||
|
||||
static const Duration _processExitTimeout = Duration(seconds: 6);
|
||||
static const Duration _pairingCleanupTimeout = Duration(seconds: 5);
|
||||
static const Duration _defaultPairingTimeout = Duration(seconds: 45);
|
||||
LinuxBlePairingService({
|
||||
ProcessStartFn? processStart,
|
||||
ProcessRunFn? processRun,
|
||||
}) : _processStart = processStart ?? Process.start,
|
||||
_processRun = processRun ?? Process.run;
|
||||
|
||||
final ProcessStartFn _processStart;
|
||||
final ProcessRunFn _processRun;
|
||||
|
||||
Future<bool> isBluetoothctlAvailable() async {
|
||||
try {
|
||||
final result = await _processRun('bluetoothctl', <String>['--version']);
|
||||
return result.exitCode == 0;
|
||||
} on ProcessException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnectDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
onLog?.call('Requesting BlueZ disconnect for $remoteId');
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call(
|
||||
'bluetoothctl unavailable, skipping BlueZ disconnect: $error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdin.writeln('disconnect $remoteId');
|
||||
process.stdin.writeln('quit');
|
||||
try {
|
||||
await process.exitCode.timeout(_processExitTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
onLog?.call('Issued bluetoothctl disconnect for $remoteId');
|
||||
}
|
||||
|
||||
Future<bool> isPairedAndTrusted(String remoteId) async {
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = await _processRun('bluetoothctl', <String>['info', remoteId]);
|
||||
} on ProcessException {
|
||||
return false;
|
||||
}
|
||||
if (result.exitCode != 0) {
|
||||
return false;
|
||||
}
|
||||
final output = (result.stdout as String).toLowerCase();
|
||||
return output.contains('paired: yes') && output.contains('trusted: yes');
|
||||
}
|
||||
|
||||
Future<bool> trustDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
onLog?.call('Requesting BlueZ trust for $remoteId');
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = await _processRun('bluetoothctl', <String>['trust', remoteId]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call('bluetoothctl unavailable, cannot trust $remoteId: $error');
|
||||
return false;
|
||||
}
|
||||
if (result.exitCode != 0) {
|
||||
onLog?.call('bluetoothctl trust failed for $remoteId: ${result.stderr}');
|
||||
return false;
|
||||
}
|
||||
final trusted = await isPairedAndTrusted(remoteId);
|
||||
onLog?.call(
|
||||
trusted
|
||||
? 'Verified BlueZ trust for $remoteId'
|
||||
: 'BlueZ trust verification failed for $remoteId',
|
||||
);
|
||||
return trusted;
|
||||
}
|
||||
|
||||
Future<bool> pairAndTrust({
|
||||
required String remoteId,
|
||||
Duration timeout = _defaultPairingTimeout,
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async {
|
||||
var removeRetryUsed = false;
|
||||
var proactivePinRetryUsed = false;
|
||||
Future<String?> Function()? currentPinProvider = onRequestPin;
|
||||
|
||||
for (var attempt = 0; attempt < _maxAttempts; attempt++) {
|
||||
final result = await _runPairingAttempt(
|
||||
remoteId: remoteId,
|
||||
timeout: timeout,
|
||||
onLog: onLog,
|
||||
onRequestPin: currentPinProvider,
|
||||
);
|
||||
|
||||
if (result.success) return true;
|
||||
if (result.userCancelled) {
|
||||
onLog?.call('Pairing cancelled by user; skipping retry/remove flow');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.pairFailed) {
|
||||
if (!removeRetryUsed) {
|
||||
removeRetryUsed = true;
|
||||
onLog?.call(
|
||||
'Pairing failed; removing cached bond and retrying '
|
||||
'(attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
await _removeDevice(remoteId, onLog: onLog);
|
||||
continue;
|
||||
}
|
||||
if (!result.pinSent &&
|
||||
!proactivePinRetryUsed &&
|
||||
currentPinProvider != null) {
|
||||
proactivePinRetryUsed = true;
|
||||
onLog?.call(
|
||||
'Pairing failed before PIN challenge; requesting PIN for '
|
||||
'proactive retry (attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
final pin = await currentPinProvider();
|
||||
if (pin == null) {
|
||||
onLog?.call('PIN entry cancelled for proactive retry');
|
||||
return false;
|
||||
}
|
||||
final capturedPin = pin.trim();
|
||||
currentPinProvider = () async => capturedPin;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Timeout path — pairing neither succeeded nor failed.
|
||||
onLog?.call('Pairing did not complete before timeout');
|
||||
if (!result.pinSent &&
|
||||
!proactivePinRetryUsed &&
|
||||
currentPinProvider != null) {
|
||||
proactivePinRetryUsed = true;
|
||||
onLog?.call(
|
||||
'No PIN challenge observed before timeout; requesting PIN for '
|
||||
'proactive retry (attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
final pin = await currentPinProvider();
|
||||
if (pin == null) {
|
||||
onLog?.call('PIN entry cancelled for proactive retry after timeout');
|
||||
return false;
|
||||
}
|
||||
final capturedPin = pin.trim();
|
||||
currentPinProvider = () async => capturedPin;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Runs a single bluetoothctl pairing attempt.
|
||||
///
|
||||
/// Uses a [Completer] to wake as soon as pairing succeeds or fails,
|
||||
/// instead of polling.
|
||||
Future<_PairingResult> _runPairingAttempt({
|
||||
required String remoteId,
|
||||
required Duration timeout,
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async {
|
||||
onLog?.call('Starting bluetoothctl pairing flow for $remoteId');
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call('bluetoothctl unavailable, cannot run pairing flow: $error');
|
||||
return const _PairingResult();
|
||||
}
|
||||
final output = StringBuffer();
|
||||
var pinSent = false;
|
||||
var sessionClosed = false;
|
||||
var userCancelledPinEntry = false;
|
||||
var confirmationHandled = false;
|
||||
var successHandled = false;
|
||||
var failureHandled = false;
|
||||
var detectorBuffer = '';
|
||||
final pairingDone = Completer<void>();
|
||||
var pairSucceeded = false;
|
||||
var pairFailed = false;
|
||||
|
||||
void writeCmd(String cmd) {
|
||||
if (sessionClosed) return;
|
||||
try {
|
||||
process.stdin.writeln(cmd);
|
||||
} on StateError {
|
||||
sessionClosed = true;
|
||||
onLog?.call('bluetoothctl stdin already closed; ignoring "$cmd"');
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(
|
||||
process.exitCode.then((_) {
|
||||
sessionClosed = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}),
|
||||
);
|
||||
|
||||
void handleChunk(String chunk) {
|
||||
output.write(chunk);
|
||||
detectorBuffer += chunk.toLowerCase();
|
||||
if (detectorBuffer.length > 4096) {
|
||||
detectorBuffer = detectorBuffer.substring(detectorBuffer.length - 4096);
|
||||
}
|
||||
final lower = detectorBuffer;
|
||||
|
||||
if (!pinSent &&
|
||||
!sessionClosed &&
|
||||
(lower.contains('enter pin code') ||
|
||||
lower.contains('requestpin') ||
|
||||
lower.contains('input pin code') ||
|
||||
lower.contains('request passkey') ||
|
||||
lower.contains('requestpasskey') ||
|
||||
lower.contains('enter passkey'))) {
|
||||
pinSent = true;
|
||||
if (onRequestPin == null) {
|
||||
onLog?.call(
|
||||
'PIN/passkey requested but no onRequestPin callback; '
|
||||
'sending empty line to accept default pairing',
|
||||
);
|
||||
writeCmd('');
|
||||
} else {
|
||||
onLog?.call('Pairing agent is ready for PIN/passkey input');
|
||||
unawaited(
|
||||
Future<void>(() async {
|
||||
String? pin;
|
||||
try {
|
||||
pin = await onRequestPin();
|
||||
} catch (e) {
|
||||
onLog?.call('onRequestPin callback threw: $e');
|
||||
pairFailed = true;
|
||||
writeCmd('cancel');
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
return;
|
||||
}
|
||||
if (pin == null) {
|
||||
if (sessionClosed) {
|
||||
onLog?.call(
|
||||
'PIN prompt resolved after pairing session closed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
onLog?.call('PIN entry cancelled by user; cancelling pairing');
|
||||
userCancelledPinEntry = true;
|
||||
pairFailed = true;
|
||||
writeCmd('cancel');
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
return;
|
||||
}
|
||||
if (sessionClosed) {
|
||||
onLog?.call(
|
||||
'PIN provided after pairing session closed; ignoring',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pin.trim().isEmpty) {
|
||||
onLog?.call(
|
||||
'Blank PIN submitted; sending empty line to accept default pairing',
|
||||
);
|
||||
writeCmd('');
|
||||
} else {
|
||||
onLog?.call('Submitting PIN/passkey to pairing agent');
|
||||
writeCmd(pin.trim());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirmationHandled &&
|
||||
(lower.contains('confirm passkey') ||
|
||||
lower.contains('requestconfirmation') ||
|
||||
lower.contains('[agent] confirm'))) {
|
||||
confirmationHandled = true;
|
||||
onLog?.call(
|
||||
'Pairing agent requested passkey confirmation; answering yes',
|
||||
);
|
||||
writeCmd('yes');
|
||||
}
|
||||
|
||||
if (!successHandled &&
|
||||
(lower.contains('pairing successful') ||
|
||||
lower.contains('already paired'))) {
|
||||
successHandled = true;
|
||||
onLog?.call('Pairing reported success');
|
||||
pairSucceeded = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}
|
||||
|
||||
if (!failureHandled &&
|
||||
(lower.contains('failed to pair') ||
|
||||
lower.contains('authenticationfailed') ||
|
||||
lower.contains('authentication failed'))) {
|
||||
failureHandled = true;
|
||||
onLog?.call('Pairing reported authentication failure');
|
||||
pairFailed = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}
|
||||
}
|
||||
|
||||
final stdoutSub = process.stdout
|
||||
.transform(utf8.decoder)
|
||||
.listen(handleChunk);
|
||||
final stderrSub = process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen(handleChunk);
|
||||
|
||||
writeCmd('power on');
|
||||
writeCmd('agent KeyboardDisplay');
|
||||
writeCmd('default-agent');
|
||||
onLog?.call('Waiting for pairing challenge from bluetoothctl agent');
|
||||
writeCmd('pair $remoteId');
|
||||
|
||||
// Wait for the Completer to fire (success/failure/process exit) or timeout.
|
||||
await pairingDone.future.timeout(timeout, onTimeout: () {});
|
||||
|
||||
if (!pairFailed && pairSucceeded) {
|
||||
onLog?.call('Pair succeeded; trusting and connecting device');
|
||||
writeCmd('trust $remoteId');
|
||||
writeCmd('connect $remoteId');
|
||||
}
|
||||
writeCmd('quit');
|
||||
sessionClosed = true;
|
||||
|
||||
try {
|
||||
await process.exitCode.timeout(_pairingCleanupTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
await stdoutSub.cancel();
|
||||
await stderrSub.cancel();
|
||||
|
||||
if (pairFailed) {
|
||||
return _PairingResult(
|
||||
pairFailed: true,
|
||||
pinSent: pinSent,
|
||||
userCancelled: userCancelledPinEntry,
|
||||
);
|
||||
}
|
||||
|
||||
final allOutput = output.toString().toLowerCase();
|
||||
final reportedSuccess =
|
||||
pairSucceeded ||
|
||||
allOutput.contains('pairing successful') ||
|
||||
allOutput.contains('already paired');
|
||||
if (reportedSuccess) {
|
||||
final trusted = await trustDevice(remoteId, onLog: onLog);
|
||||
if (!trusted) {
|
||||
onLog?.call('Pairing completed but BlueZ trust was not restored');
|
||||
}
|
||||
return _PairingResult(success: trusted, pinSent: pinSent);
|
||||
}
|
||||
|
||||
return _PairingResult(pinSent: pinSent);
|
||||
}
|
||||
|
||||
Future<void> _removeDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call(
|
||||
'bluetoothctl unavailable, skipping remove for $remoteId: $error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdin.writeln('remove $remoteId');
|
||||
process.stdin.writeln('quit');
|
||||
try {
|
||||
await process.exitCode.timeout(_processExitTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
onLog?.call('Issued bluetoothctl remove for $remoteId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a single bluetoothctl pairing attempt.
|
||||
class _PairingResult {
|
||||
final bool success;
|
||||
final bool pairFailed;
|
||||
final bool pinSent;
|
||||
final bool userCancelled;
|
||||
|
||||
const _PairingResult({
|
||||
this.success = false,
|
||||
this.pairFailed = false,
|
||||
this.pinSent = false,
|
||||
this.userCancelled = false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// No-op stub for web builds where dart:io is unavailable.
|
||||
///
|
||||
/// The real implementation lives in linux_ble_pairing_service.dart and is
|
||||
/// selected via conditional import in meshcore_connector.dart.
|
||||
class LinuxBlePairingService {
|
||||
LinuxBlePairingService();
|
||||
|
||||
Future<bool> isBluetoothctlAvailable() async => false;
|
||||
|
||||
Future<void> disconnectDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {}
|
||||
|
||||
Future<bool> isPairedAndTrusted(String remoteId) async => false;
|
||||
|
||||
Future<bool> trustDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async => false;
|
||||
|
||||
Future<bool> pairAndTrust({
|
||||
required String remoteId,
|
||||
Duration timeout = const Duration(seconds: 45),
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async => false;
|
||||
}
|
||||
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/foundation.dart';
|
||||
|
||||
import '../helpers/reaction_helper.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
|
||||
@@ -145,6 +146,19 @@ class NotificationService {
|
||||
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({
|
||||
required String contactName,
|
||||
required String message,
|
||||
@@ -187,7 +201,7 @@ class NotificationService {
|
||||
await _notifications.show(
|
||||
id: contactId?.hashCode ?? 0,
|
||||
title: contactName,
|
||||
body: message,
|
||||
body: formatNotificationText(message),
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'message:$contactId',
|
||||
);
|
||||
@@ -283,7 +297,7 @@ class NotificationService {
|
||||
macOS: macDetails,
|
||||
);
|
||||
|
||||
final preview = message.trim();
|
||||
final preview = formatNotificationText(message.trim());
|
||||
final body = preview.isEmpty
|
||||
? _l10n.notification_receivedNewMessage
|
||||
: preview;
|
||||
@@ -430,6 +444,7 @@ class NotificationService {
|
||||
|
||||
Future<void> showChannelMessageNotification({
|
||||
required String channelName,
|
||||
required String senderName,
|
||||
required String message,
|
||||
int? channelIndex,
|
||||
int? badgeCount,
|
||||
@@ -440,7 +455,7 @@ class NotificationService {
|
||||
_PendingNotification(
|
||||
type: _NotificationType.channelMessage,
|
||||
title: channelName,
|
||||
body: message,
|
||||
body: '$senderName: $message',
|
||||
id: channelIndex?.toString(),
|
||||
badgeCount: badgeCount,
|
||||
),
|
||||
|
||||
@@ -9,6 +9,8 @@ class PathHistoryService extends ChangeNotifier {
|
||||
final Map<String, ContactPathHistory> _cache = {};
|
||||
final Map<String, int> _autoRotationIndex = {};
|
||||
final Map<String, _FloodStats> _floodStats = {};
|
||||
final Set<String> _pendingLoads = {};
|
||||
final Map<String, List<_DeferredPathRecord>> _deferredRecords = {};
|
||||
|
||||
// LRU cache eviction tracking
|
||||
static const int _maxCachedContacts = 50;
|
||||
@@ -18,7 +20,6 @@ class PathHistoryService extends ChangeNotifier {
|
||||
|
||||
int _version = 0;
|
||||
int get version => _version;
|
||||
static const int _autoRotationTopCount = 3;
|
||||
|
||||
PathHistoryService(this._storage);
|
||||
|
||||
@@ -26,17 +27,21 @@ class PathHistoryService extends ChangeNotifier {
|
||||
// Load cached path histories on startup if needed
|
||||
}
|
||||
|
||||
void handlePathUpdated(Contact contact) {
|
||||
if (contact.pathLength < 0) return;
|
||||
|
||||
void handlePathUpdated(Contact contact, {double initialWeight = 1.0}) {
|
||||
if (contact.pathLength < 0 && contact.path.isEmpty) return;
|
||||
final hopCount = contact.pathLength < 0
|
||||
? contact.path.length
|
||||
: contact.pathLength;
|
||||
_addPathRecord(
|
||||
contactPubKeyHex: contact.publicKeyHex,
|
||||
hopCount: contact.pathLength,
|
||||
hopCount: hopCount,
|
||||
tripTimeMs: 0,
|
||||
wasFloodDiscovery: true,
|
||||
pathBytes: contact.path,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
routeWeight: initialWeight,
|
||||
timestamp: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +59,44 @@ class PathHistoryService extends ChangeNotifier {
|
||||
pathBytes: selection.pathBytes,
|
||||
successCount: 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, {
|
||||
required bool success,
|
||||
int? tripTimeMs,
|
||||
double successIncrement = 0.5,
|
||||
double failureDecrement = 0.5,
|
||||
double maxWeight = 5.0,
|
||||
}) {
|
||||
if (selection.useFlood) {
|
||||
final stats = _floodStats.putIfAbsent(
|
||||
@@ -82,6 +128,18 @@ class PathHistoryService extends ChangeNotifier {
|
||||
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
|
||||
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(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
hopCount: selection.hopCount,
|
||||
@@ -90,37 +148,68 @@ class PathHistoryService extends ChangeNotifier {
|
||||
pathBytes: selection.pathBytes,
|
||||
successCount: successCount,
|
||||
failureCount: failureCount,
|
||||
routeWeight: newWeight,
|
||||
timestamp: success ? DateTime.now() : existing?.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
|
||||
final ranked = _getRankedPaths(
|
||||
contactPubKeyHex,
|
||||
).take(_autoRotationTopCount).toList();
|
||||
PathSelection selectPathForAttempt(
|
||||
String contactPubKeyHex, {
|
||||
required int attemptIndex,
|
||||
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) {
|
||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||
}
|
||||
|
||||
_trackAccess(contactPubKeyHex);
|
||||
|
||||
final selections =
|
||||
ranked
|
||||
.map(
|
||||
(path) => PathSelection(
|
||||
pathBytes: path.pathBytes,
|
||||
hopCount: path.hopCount,
|
||||
useFlood: false,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..add(
|
||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
||||
);
|
||||
final recentPaths = recentSelections
|
||||
.where((selection) => !selection.useFlood)
|
||||
.map((selection) => selection.pathBytes)
|
||||
.toList();
|
||||
final candidates = recentPaths.isEmpty
|
||||
? ranked
|
||||
: ranked
|
||||
.where(
|
||||
(path) => !recentPaths.any(
|
||||
(recentPath) => _pathsEqual(path.pathBytes, recentPath),
|
||||
),
|
||||
)
|
||||
.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 selection = selections[currentIndex % selections.length];
|
||||
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
|
||||
return selection;
|
||||
final selectedIndex = currentIndex % candidates.length;
|
||||
_autoRotationIndex[contactPubKeyHex] =
|
||||
(selectedIndex + 1) % candidates.length;
|
||||
return candidates[selectedIndex];
|
||||
}
|
||||
|
||||
void _addPathRecord({
|
||||
@@ -131,37 +220,68 @@ class PathHistoryService extends ChangeNotifier {
|
||||
required List<int> pathBytes,
|
||||
required int successCount,
|
||||
required int failureCount,
|
||||
double routeWeight = 1.0,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
var history = _cache[contactPubKeyHex];
|
||||
|
||||
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) {
|
||||
if (loaded != null) {
|
||||
_cache[contactPubKeyHex] = loaded;
|
||||
_addPathRecordInternal(
|
||||
contactPubKeyHex,
|
||||
hopCount,
|
||||
tripTimeMs,
|
||||
wasFloodDiscovery,
|
||||
pathBytes,
|
||||
successCount,
|
||||
failureCount,
|
||||
);
|
||||
} else {
|
||||
_cache[contactPubKeyHex] = ContactPathHistory(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
recentPaths: [],
|
||||
);
|
||||
_addPathRecordInternal(
|
||||
contactPubKeyHex,
|
||||
hopCount,
|
||||
tripTimeMs,
|
||||
wasFloodDiscovery,
|
||||
pathBytes,
|
||||
successCount,
|
||||
failureCount,
|
||||
);
|
||||
_cache[contactPubKeyHex] =
|
||||
loaded ??
|
||||
ContactPathHistory(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
recentPaths: [],
|
||||
);
|
||||
_addPathRecordInternal(
|
||||
contactPubKeyHex,
|
||||
hopCount,
|
||||
tripTimeMs,
|
||||
wasFloodDiscovery,
|
||||
pathBytes,
|
||||
successCount,
|
||||
failureCount,
|
||||
routeWeight,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
// Apply any deferred records
|
||||
final deferred = _deferredRecords.remove(contactPubKeyHex);
|
||||
if (deferred != null) {
|
||||
for (final record in deferred) {
|
||||
_addPathRecordInternal(
|
||||
contactPubKeyHex,
|
||||
record.hopCount,
|
||||
record.tripTimeMs,
|
||||
record.wasFloodDiscovery,
|
||||
record.pathBytes,
|
||||
record.successCount,
|
||||
record.failureCount,
|
||||
record.routeWeight,
|
||||
record.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
_pendingLoads.remove(contactPubKeyHex);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -174,6 +294,8 @@ class PathHistoryService extends ChangeNotifier {
|
||||
pathBytes,
|
||||
successCount,
|
||||
failureCount,
|
||||
routeWeight,
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,6 +307,8 @@ class PathHistoryService extends ChangeNotifier {
|
||||
List<int> pathBytes,
|
||||
int successCount,
|
||||
int failureCount,
|
||||
double routeWeight,
|
||||
DateTime? timestamp,
|
||||
) {
|
||||
var history = _cache[contactPubKeyHex];
|
||||
if (history == null) return;
|
||||
@@ -198,16 +322,18 @@ class PathHistoryService extends ChangeNotifier {
|
||||
tripTimeMs = existing.tripTimeMs;
|
||||
}
|
||||
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
|
||||
timestamp ??= existing.timestamp;
|
||||
}
|
||||
|
||||
final newRecord = PathRecord(
|
||||
hopCount: hopCount,
|
||||
tripTimeMs: tripTimeMs,
|
||||
timestamp: DateTime.now(),
|
||||
timestamp: timestamp,
|
||||
wasFloodDiscovery: wasFloodDiscovery,
|
||||
pathBytes: pathBytes,
|
||||
successCount: successCount,
|
||||
failureCount: failureCount,
|
||||
routeWeight: routeWeight,
|
||||
);
|
||||
|
||||
final updatedPaths = List<PathRecord>.from(history.recentPaths);
|
||||
@@ -275,6 +401,23 @@ class PathHistoryService extends ChangeNotifier {
|
||||
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 {
|
||||
_cache.remove(contactPubKeyHex);
|
||||
_cacheAccessOrder.remove(contactPubKeyHex);
|
||||
@@ -322,26 +465,81 @@ class PathHistoryService extends ChangeNotifier {
|
||||
|
||||
final ranked = List<PathRecord>.from(history.recentPaths)
|
||||
..removeWhere((p) => p.pathBytes.isEmpty);
|
||||
final fastestTripMs = _getFastestKnownTripMs(ranked);
|
||||
final highestRouteWeight = _getHighestKnownRouteWeight(ranked);
|
||||
|
||||
ranked.sort((a, b) {
|
||||
final aRate =
|
||||
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
|
||||
final bRate =
|
||||
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
|
||||
if (aRate != bRate) return bRate.compareTo(aRate);
|
||||
if (a.successCount != b.successCount) {
|
||||
return b.successCount.compareTo(a.successCount);
|
||||
final scoreCompare =
|
||||
_scorePathRecord(
|
||||
b,
|
||||
fastestTripMs: fastestTripMs,
|
||||
highestRouteWeight: highestRouteWeight,
|
||||
).compareTo(
|
||||
_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 bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
return listEquals(a, b);
|
||||
}
|
||||
@@ -367,6 +565,38 @@ class PathHistoryService extends ChangeNotifier {
|
||||
_floodStats.remove(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
void clearAllHistories() {
|
||||
_cache.clear();
|
||||
_cacheAccessOrder.clear();
|
||||
_autoRotationIndex.clear();
|
||||
_floodStats.clear();
|
||||
_storage.clearAllPathHistories();
|
||||
_version = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'translation_file_store_stub.dart'
|
||||
if (dart.library.io) 'translation_file_store_io.dart';
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../models/translation_support.dart';
|
||||
|
||||
class TranslationFileStore {
|
||||
Future<String> modelDirectoryPath() async {
|
||||
final baseDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${baseDir.path}/translation_models');
|
||||
if (!dir.existsSync()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir.path;
|
||||
}
|
||||
|
||||
Future<List<TranslationModelRecord>> scanDownloadedModels() async {
|
||||
final dir = Directory(await modelDirectoryPath());
|
||||
if (!dir.existsSync()) {
|
||||
return const [];
|
||||
}
|
||||
final models = <TranslationModelRecord>[];
|
||||
for (final entity in dir.listSync().whereType<File>()) {
|
||||
final name = entity.uri.pathSegments.last;
|
||||
// Skip hidden chunk files from interrupted parallel downloads.
|
||||
if (name.startsWith('.')) {
|
||||
await entity.delete();
|
||||
continue;
|
||||
}
|
||||
final stat = entity.statSync();
|
||||
models.add(
|
||||
TranslationModelRecord(
|
||||
id: name,
|
||||
name: name,
|
||||
sourceUrl: '',
|
||||
localPath: entity.path,
|
||||
downloadedAt: stat.modified,
|
||||
fileSizeBytes: stat.size,
|
||||
),
|
||||
);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
Future<void> deleteModel(TranslationModelRecord model) async {
|
||||
await deleteFile(model.localPath);
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String path) async {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<DownloadedModelFile> writeModelBytes({
|
||||
required String fileName,
|
||||
required Stream<List<int>> chunks,
|
||||
}) async {
|
||||
final directoryPath = await modelDirectoryPath();
|
||||
final file = File('$directoryPath/$fileName');
|
||||
final sink = file.openWrite();
|
||||
var fileSizeBytes = 0;
|
||||
var completed = false;
|
||||
try {
|
||||
await for (final chunk in chunks) {
|
||||
sink.add(chunk);
|
||||
fileSizeBytes += chunk.length;
|
||||
}
|
||||
completed = true;
|
||||
} finally {
|
||||
await sink.close();
|
||||
if (!completed && file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
return DownloadedModelFile(
|
||||
localPath: file.path,
|
||||
fileSizeBytes: fileSizeBytes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> chunkFilePath(String fileName, int index) async {
|
||||
final dir = await modelDirectoryPath();
|
||||
return '$dir/.${fileName}_chunk_$index';
|
||||
}
|
||||
|
||||
Future<DownloadedModelFile> combineChunks({
|
||||
required String fileName,
|
||||
required List<String> chunkPaths,
|
||||
}) async {
|
||||
final dir = await modelDirectoryPath();
|
||||
final finalPath = '$dir/$fileName';
|
||||
final sink = File(finalPath).openWrite();
|
||||
var totalSize = 0;
|
||||
var completed = false;
|
||||
try {
|
||||
for (final chunkPath in chunkPaths) {
|
||||
final chunkFile = File(chunkPath);
|
||||
await sink.addStream(chunkFile.openRead());
|
||||
totalSize += await chunkFile.length();
|
||||
}
|
||||
completed = true;
|
||||
} finally {
|
||||
await sink.close();
|
||||
for (final chunkPath in chunkPaths) {
|
||||
final file = File(chunkPath);
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
if (!completed) {
|
||||
final finalFile = File(finalPath);
|
||||
if (finalFile.existsSync()) {
|
||||
await finalFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
return DownloadedModelFile(localPath: finalPath, fileSizeBytes: totalSize);
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadedModelFile {
|
||||
final String localPath;
|
||||
final int fileSizeBytes;
|
||||
|
||||
const DownloadedModelFile({
|
||||
required this.localPath,
|
||||
required this.fileSizeBytes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import '../models/translation_support.dart';
|
||||
|
||||
class TranslationFileStore {
|
||||
Future<String> modelDirectoryPath() async {
|
||||
throw UnsupportedError('Local model storage is not supported on web.');
|
||||
}
|
||||
|
||||
Future<List<TranslationModelRecord>> scanDownloadedModels() async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
Future<void> deleteModel(TranslationModelRecord model) async {}
|
||||
|
||||
Future<void> deleteFile(String path) async {}
|
||||
|
||||
Future<DownloadedModelFile> writeModelBytes({
|
||||
required String fileName,
|
||||
required Stream<List<int>> chunks,
|
||||
}) async {
|
||||
throw UnsupportedError('Local model downloads are not supported on web.');
|
||||
}
|
||||
|
||||
Future<String> chunkFilePath(String fileName, int index) async {
|
||||
throw UnsupportedError('Local model downloads are not supported on web.');
|
||||
}
|
||||
|
||||
Future<DownloadedModelFile> combineChunks({
|
||||
required String fileName,
|
||||
required List<String> chunkPaths,
|
||||
}) async {
|
||||
throw UnsupportedError('Local model downloads are not supported on web.');
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadedModelFile {
|
||||
final String localPath;
|
||||
final int fileSizeBytes;
|
||||
|
||||
const DownloadedModelFile({
|
||||
required this.localPath,
|
||||
required this.fileSizeBytes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:llamadart/llamadart.dart';
|
||||
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/translation_support.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'app_settings_service.dart';
|
||||
import 'translation_file_store.dart';
|
||||
|
||||
class TranslationResult {
|
||||
final String translatedText;
|
||||
final String targetLanguageCode;
|
||||
final String? detectedLanguageCode;
|
||||
final String? modelId;
|
||||
final MessageTranslationStatus status;
|
||||
|
||||
const TranslationResult({
|
||||
required this.translatedText,
|
||||
required this.targetLanguageCode,
|
||||
required this.status,
|
||||
this.detectedLanguageCode,
|
||||
this.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
class TranslationDownloadCancelled implements Exception {
|
||||
const TranslationDownloadCancelled();
|
||||
|
||||
@override
|
||||
String toString() => 'Download canceled.';
|
||||
}
|
||||
|
||||
class TranslationService extends ChangeNotifier {
|
||||
final AppSettingsService _appSettingsService;
|
||||
final TranslationFileStore _fileStore;
|
||||
|
||||
TranslationService(
|
||||
this._appSettingsService, {
|
||||
TranslationFileStore? fileStore,
|
||||
}) : _fileStore = fileStore ?? TranslationFileStore();
|
||||
|
||||
bool _isBusy = false;
|
||||
bool _isDownloading = false;
|
||||
bool _cancelDownloadRequested = false;
|
||||
String? _lastError;
|
||||
Future<void> _queue = Future<void>.value();
|
||||
LlamaEngine? _engine;
|
||||
String? _loadedModelPath;
|
||||
String? _failedModelPath;
|
||||
int _downloadedBytes = 0;
|
||||
int? _downloadTotalBytes;
|
||||
String? _downloadFileName;
|
||||
|
||||
bool get isBusy => _isBusy;
|
||||
bool get isDownloading => _isDownloading;
|
||||
String? get lastError => _lastError;
|
||||
int get downloadedBytes => _downloadedBytes;
|
||||
int? get downloadTotalBytes => _downloadTotalBytes;
|
||||
String? get downloadFileName => _downloadFileName;
|
||||
double? get downloadProgress {
|
||||
final total = _downloadTotalBytes;
|
||||
if (!_isDownloading || total == null || total <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (_downloadedBytes / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
AppSettings get _settings => _appSettingsService.settings;
|
||||
|
||||
String? resolvedTargetLanguageCode(String? fallbackLanguageCode) {
|
||||
return _settings.translationTargetLanguageCode ??
|
||||
_settings.languageOverride ??
|
||||
fallbackLanguageCode;
|
||||
}
|
||||
|
||||
String? resolvedIncomingLanguageCode(String? fallbackLanguageCode) {
|
||||
return _settings.translationTargetLanguageCode ??
|
||||
_settings.languageOverride ??
|
||||
fallbackLanguageCode ??
|
||||
'en';
|
||||
}
|
||||
|
||||
bool shouldTranslateIncoming({
|
||||
required String text,
|
||||
required bool isCli,
|
||||
required bool isOutgoing,
|
||||
}) {
|
||||
if (!_settings.translationEnabled || isCli || isOutgoing) {
|
||||
return false;
|
||||
}
|
||||
return _isPlainTextEligible(text);
|
||||
}
|
||||
|
||||
bool shouldTranslateOutgoing({
|
||||
required String text,
|
||||
required String? targetLanguageCode,
|
||||
}) {
|
||||
return _settings.composerTranslationEnabled &&
|
||||
targetLanguageCode != null &&
|
||||
targetLanguageCode.isNotEmpty &&
|
||||
_isPlainTextEligible(text);
|
||||
}
|
||||
|
||||
List<TranslationModelRecord> get availableModels =>
|
||||
_settings.translationDownloadedModels;
|
||||
|
||||
TranslationModelRecord? get selectedModel {
|
||||
final selectedId = _settings.translationSelectedModelId;
|
||||
if (selectedId == null) {
|
||||
return availableModels.isNotEmpty ? availableModels.first : null;
|
||||
}
|
||||
for (final model in availableModels) {
|
||||
if (model.id == selectedId) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
return availableModels.isNotEmpty ? availableModels.first : null;
|
||||
}
|
||||
|
||||
Future<void> refreshDownloadedModels() async {
|
||||
if (_isDownloading) return;
|
||||
final scanned = await _fileStore.scanDownloadedModels();
|
||||
if (scanned.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final existingByPath = {
|
||||
for (final model in _settings.translationDownloadedModels)
|
||||
model.localPath: model,
|
||||
};
|
||||
final merged = scanned.map((model) {
|
||||
final existing = existingByPath[model.localPath];
|
||||
if (existing == null) {
|
||||
return model;
|
||||
}
|
||||
return TranslationModelRecord(
|
||||
id: existing.id,
|
||||
name: existing.name,
|
||||
sourceUrl: existing.sourceUrl,
|
||||
localPath: existing.localPath,
|
||||
downloadedAt: existing.downloadedAt,
|
||||
fileSizeBytes: model.fileSizeBytes,
|
||||
);
|
||||
}).toList();
|
||||
await _appSettingsService.setTranslationDownloadedModels(merged);
|
||||
_failedModelPath = null;
|
||||
if (_settings.translationSelectedModelId == null && merged.isNotEmpty) {
|
||||
await _appSettingsService.setTranslationSelectedModelId(merged.first.id);
|
||||
}
|
||||
}
|
||||
|
||||
static const int _parallelChunks = 8;
|
||||
static const int _parallelMinBytes = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
Future<TranslationModelRecord> downloadModel({
|
||||
required String sourceUrl,
|
||||
String? fileName,
|
||||
String? id,
|
||||
}) async {
|
||||
final uri = Uri.tryParse(sourceUrl);
|
||||
if (uri == null || !uri.hasScheme) {
|
||||
throw ArgumentError('Invalid model URL.');
|
||||
}
|
||||
return _runExclusive(() async {
|
||||
_setBusy(true);
|
||||
_setDownloading(true);
|
||||
_lastError = null;
|
||||
try {
|
||||
final resolvedFileName =
|
||||
fileName ??
|
||||
_sanitizeFileName(
|
||||
uri.pathSegments.isNotEmpty
|
||||
? uri.pathSegments.last
|
||||
: 'translation-model.gguf',
|
||||
);
|
||||
_downloadFileName = resolvedFileName;
|
||||
_downloadedBytes = 0;
|
||||
_cancelDownloadRequested = false;
|
||||
|
||||
// HEAD request to check size and range support.
|
||||
final headClient = http.Client();
|
||||
int? totalSize;
|
||||
bool supportsRange = false;
|
||||
try {
|
||||
final headResponse = await headClient.send(http.Request('HEAD', uri));
|
||||
totalSize = headResponse.contentLength;
|
||||
supportsRange =
|
||||
headResponse.headers['accept-ranges']?.contains('bytes') == true;
|
||||
await headResponse.stream.drain<void>();
|
||||
} finally {
|
||||
headClient.close();
|
||||
}
|
||||
|
||||
_downloadTotalBytes = totalSize;
|
||||
notifyListeners();
|
||||
|
||||
DownloadedModelFile downloaded;
|
||||
if (supportsRange &&
|
||||
totalSize != null &&
|
||||
totalSize > _parallelMinBytes) {
|
||||
downloaded = await _downloadParallel(
|
||||
uri: uri,
|
||||
fileName: resolvedFileName,
|
||||
totalSize: totalSize,
|
||||
);
|
||||
} else {
|
||||
downloaded = await _downloadSingle(
|
||||
uri: uri,
|
||||
fileName: resolvedFileName,
|
||||
);
|
||||
}
|
||||
|
||||
final record = TranslationModelRecord(
|
||||
id: id ?? resolvedFileName,
|
||||
name: resolvedFileName,
|
||||
sourceUrl: sourceUrl,
|
||||
localPath: downloaded.localPath,
|
||||
downloadedAt: DateTime.now(),
|
||||
fileSizeBytes: downloaded.fileSizeBytes,
|
||||
);
|
||||
final updated = [
|
||||
for (final existing in _settings.translationDownloadedModels)
|
||||
if (existing.id != record.id) existing,
|
||||
record,
|
||||
];
|
||||
await _appSettingsService.setTranslationDownloadedModels(updated);
|
||||
await _appSettingsService.setTranslationSelectedModelId(record.id);
|
||||
await _appSettingsService.setTranslationModelSourceUrl(sourceUrl);
|
||||
_failedModelPath = null;
|
||||
return record;
|
||||
} finally {
|
||||
_setDownloading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<DownloadedModelFile> _downloadSingle({
|
||||
required Uri uri,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final response = await client.send(http.Request('GET', uri));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw StateError('Model download failed: HTTP ${response.statusCode}');
|
||||
}
|
||||
_downloadTotalBytes ??= response.contentLength;
|
||||
notifyListeners();
|
||||
final trackedStream = _trackDownloadProgress(response.stream);
|
||||
return await _fileStore.writeModelBytes(
|
||||
fileName: fileName,
|
||||
chunks: trackedStream,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<DownloadedModelFile> _downloadParallel({
|
||||
required Uri uri,
|
||||
required String fileName,
|
||||
required int totalSize,
|
||||
}) async {
|
||||
final chunkSize = (totalSize / _parallelChunks).ceil();
|
||||
final chunkPaths = <String>[];
|
||||
final clients = <http.Client>[];
|
||||
var combineReached = false;
|
||||
try {
|
||||
final futures = <Future<void>>[];
|
||||
for (var i = 0; i < _parallelChunks; i++) {
|
||||
final start = i * chunkSize;
|
||||
final end = (start + chunkSize - 1).clamp(0, totalSize - 1);
|
||||
if (start >= totalSize) break;
|
||||
final chunkPath = await _fileStore.chunkFilePath(fileName, i);
|
||||
chunkPaths.add(chunkPath);
|
||||
final client = http.Client();
|
||||
clients.add(client);
|
||||
futures.add(
|
||||
_downloadRange(
|
||||
client: client,
|
||||
uri: uri,
|
||||
chunkPath: chunkPath,
|
||||
start: start,
|
||||
end: end,
|
||||
),
|
||||
);
|
||||
}
|
||||
await Future.wait(futures);
|
||||
if (_cancelDownloadRequested) {
|
||||
throw const TranslationDownloadCancelled();
|
||||
}
|
||||
_downloadFileName = 'Merging chunks...';
|
||||
notifyListeners();
|
||||
combineReached = true;
|
||||
return await _fileStore.combineChunks(
|
||||
fileName: fileName,
|
||||
chunkPaths: chunkPaths,
|
||||
);
|
||||
} finally {
|
||||
for (final client in clients) {
|
||||
client.close();
|
||||
}
|
||||
if (!combineReached) {
|
||||
for (final chunkPath in chunkPaths) {
|
||||
await _fileStore.deleteFile(chunkPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadRange({
|
||||
required http.Client client,
|
||||
required Uri uri,
|
||||
required String chunkPath,
|
||||
required int start,
|
||||
required int end,
|
||||
}) async {
|
||||
final request = http.Request('GET', uri);
|
||||
request.headers['Range'] = 'bytes=$start-$end';
|
||||
final response = await client.send(request);
|
||||
if (response.statusCode != 206) {
|
||||
await response.stream.drain<void>();
|
||||
throw StateError(
|
||||
'Range download failed: HTTP ${response.statusCode}'
|
||||
'${response.statusCode == 200 ? ' (server ignored Range header)' : ''}',
|
||||
);
|
||||
}
|
||||
final trackedStream = _trackDownloadProgress(response.stream);
|
||||
await _fileStore.writeModelBytes(
|
||||
fileName: chunkPath.split(RegExp(r'[/\\]')).last,
|
||||
chunks: trackedStream,
|
||||
);
|
||||
}
|
||||
|
||||
void cancelDownload() {
|
||||
if (!_isDownloading) {
|
||||
return;
|
||||
}
|
||||
_cancelDownloadRequested = true;
|
||||
_lastError = 'Download stopped.';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeModel(TranslationModelRecord model) async {
|
||||
await _runExclusive(() async {
|
||||
_setBusy(true);
|
||||
_lastError = null;
|
||||
await _fileStore.deleteModel(model);
|
||||
final updated = _settings.translationDownloadedModels
|
||||
.where((entry) => entry.id != model.id)
|
||||
.toList();
|
||||
await _appSettingsService.setTranslationDownloadedModels(updated);
|
||||
if (_settings.translationSelectedModelId == model.id) {
|
||||
await _appSettingsService.setTranslationSelectedModelId(
|
||||
updated.isNotEmpty ? updated.first.id : null,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<TranslationResult?> translateIncomingText({
|
||||
required String text,
|
||||
required String? targetLanguageCode,
|
||||
}) async {
|
||||
if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
|
||||
return null;
|
||||
}
|
||||
final detectedLanguageCode = await detectLanguage(text);
|
||||
if (detectedLanguageCode != null &&
|
||||
detectedLanguageCode == targetLanguageCode) {
|
||||
return const TranslationResult(
|
||||
translatedText: '',
|
||||
targetLanguageCode: '',
|
||||
status: MessageTranslationStatus.skipped,
|
||||
);
|
||||
}
|
||||
final translatedText = await _translateText(
|
||||
text: text,
|
||||
targetLanguageCode: targetLanguageCode,
|
||||
sourceLanguageCode: detectedLanguageCode,
|
||||
);
|
||||
if (translatedText == null || translatedText.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
// If translation is nearly identical, text was already in target language.
|
||||
if (translatedText.trim().toLowerCase() == text.trim().toLowerCase()) {
|
||||
return const TranslationResult(
|
||||
translatedText: '',
|
||||
targetLanguageCode: '',
|
||||
status: MessageTranslationStatus.skipped,
|
||||
);
|
||||
}
|
||||
return TranslationResult(
|
||||
translatedText: translatedText.trim(),
|
||||
targetLanguageCode: targetLanguageCode,
|
||||
detectedLanguageCode: detectedLanguageCode,
|
||||
modelId: selectedModel?.id,
|
||||
status: MessageTranslationStatus.completed,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TranslationResult?> translateOutgoingText({
|
||||
required String text,
|
||||
required String? targetLanguageCode,
|
||||
}) async {
|
||||
if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
|
||||
return null;
|
||||
}
|
||||
final detectedLanguageCode = await detectLanguage(text);
|
||||
if (detectedLanguageCode != null &&
|
||||
detectedLanguageCode == targetLanguageCode) {
|
||||
return const TranslationResult(
|
||||
translatedText: '',
|
||||
targetLanguageCode: '',
|
||||
status: MessageTranslationStatus.skipped,
|
||||
);
|
||||
}
|
||||
final translatedText = await _translateText(
|
||||
text: text,
|
||||
targetLanguageCode: targetLanguageCode,
|
||||
sourceLanguageCode: detectedLanguageCode,
|
||||
);
|
||||
if (translatedText == null || translatedText.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return TranslationResult(
|
||||
translatedText: translatedText.trim(),
|
||||
targetLanguageCode: targetLanguageCode,
|
||||
detectedLanguageCode: detectedLanguageCode,
|
||||
modelId: selectedModel?.id,
|
||||
status: MessageTranslationStatus.completed,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> detectLanguage(String text) async {
|
||||
return _heuristicLanguageCode(text);
|
||||
}
|
||||
|
||||
Future<String?> _translateText({
|
||||
required String text,
|
||||
required String targetLanguageCode,
|
||||
String? sourceLanguageCode,
|
||||
}) async {
|
||||
if (!_hasUsableModel) {
|
||||
return null;
|
||||
}
|
||||
final model = selectedModel;
|
||||
if (model == null || model.localPath.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final targetLabel = _languageLabel(targetLanguageCode);
|
||||
final instruction = targetLanguageCode == 'zh'
|
||||
? '将以下文本翻译为中文,注意只需要输出翻译后的结果,不要额外解释:\n\n$text'
|
||||
: 'Translate the following segment into $targetLabel, without additional explanation.\n\n$text';
|
||||
try {
|
||||
return await _runExclusive(() async {
|
||||
final engine = await _ensureContext(model.localPath);
|
||||
if (engine == null) {
|
||||
return null;
|
||||
}
|
||||
final messages = [
|
||||
LlamaChatMessage.fromText(
|
||||
role: LlamaChatRole.user,
|
||||
text: instruction,
|
||||
),
|
||||
];
|
||||
final output = StringBuffer();
|
||||
await for (final chunk in engine.create(
|
||||
messages,
|
||||
params: const GenerationParams(
|
||||
maxTokens: 256,
|
||||
temp: 0.7,
|
||||
topK: 20,
|
||||
topP: 0.6,
|
||||
penalty: 1.05,
|
||||
reusePromptPrefix: false,
|
||||
),
|
||||
enableThinking: false,
|
||||
sourceLangCode: sourceLanguageCode,
|
||||
targetLangCode: targetLanguageCode,
|
||||
)) {
|
||||
final content = chunk.choices.firstOrNull?.delta.content;
|
||||
if (content != null) {
|
||||
output.write(content);
|
||||
}
|
||||
if (output.length >= text.length * 4 + 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return _sanitizeOutput(output.toString());
|
||||
});
|
||||
} catch (error) {
|
||||
_lastError = error.toString();
|
||||
appLogger.warn('Translation request failed: $error');
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _hasUsableModel {
|
||||
final model = selectedModel;
|
||||
return !kIsWeb && model != null && model.localPath.isNotEmpty;
|
||||
}
|
||||
|
||||
bool _isPlainTextEligible(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return !(trimmed.startsWith('g:') ||
|
||||
trimmed.startsWith('m:') ||
|
||||
trimmed.startsWith('V1|') ||
|
||||
trimmed.startsWith('r:'));
|
||||
}
|
||||
|
||||
String? _heuristicLanguageCode(String text) {
|
||||
if (RegExp(r'[іїєґІЇЄҐ]').hasMatch(text)) {
|
||||
return 'uk';
|
||||
}
|
||||
if (RegExp(r'[а-яёА-ЯЁ]').hasMatch(text)) {
|
||||
return 'ru';
|
||||
}
|
||||
if (RegExp(r'[ぁ-んァ-ン]').hasMatch(text)) {
|
||||
return 'ja';
|
||||
}
|
||||
if (RegExp(r'[가-힣]').hasMatch(text)) {
|
||||
return 'ko';
|
||||
}
|
||||
if (RegExp(r'[\u4e00-\u9fff]').hasMatch(text)) {
|
||||
return 'zh';
|
||||
}
|
||||
// Latin-script languages can't be reliably distinguished by characters
|
||||
// alone — return null so the translator always attempts translation.
|
||||
return null;
|
||||
}
|
||||
|
||||
String _languageLabel(String code) {
|
||||
for (final option in supportedTranslationLanguages) {
|
||||
if (option.code == code) {
|
||||
return option.label;
|
||||
}
|
||||
}
|
||||
return code.toUpperCase();
|
||||
}
|
||||
|
||||
String _sanitizeOutput(String raw) {
|
||||
var result = raw.trim();
|
||||
result = result.replaceAll(RegExp(r'\*\*'), '');
|
||||
result = result.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
String _sanitizeFileName(String fileName) {
|
||||
final cleaned = fileName.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
|
||||
return cleaned.isEmpty ? 'translation-model.gguf' : cleaned;
|
||||
}
|
||||
|
||||
Future<LlamaEngine?> _ensureContext(String modelPath) async {
|
||||
if (_engine != null && _loadedModelPath == modelPath) {
|
||||
return _engine;
|
||||
}
|
||||
if (modelPath == _failedModelPath) {
|
||||
return null;
|
||||
}
|
||||
if (_engine != null) {
|
||||
await _engine!.dispose();
|
||||
_engine = null;
|
||||
_loadedModelPath = null;
|
||||
}
|
||||
final engine = LlamaEngine(LlamaBackend());
|
||||
try {
|
||||
await engine.loadModel(
|
||||
modelPath,
|
||||
modelParams: const ModelParams(
|
||||
gpuLayers: 0,
|
||||
preferredBackend: GpuBackend.cpu,
|
||||
),
|
||||
);
|
||||
_engine = engine;
|
||||
_loadedModelPath = modelPath;
|
||||
_failedModelPath = null;
|
||||
return _engine;
|
||||
} catch (_) {
|
||||
await engine.dispose();
|
||||
_failedModelPath = modelPath;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> releaseModel() async {
|
||||
await _runExclusive(() async {
|
||||
final engine = _engine;
|
||||
if (engine == null) {
|
||||
_loadedModelPath = null;
|
||||
return;
|
||||
}
|
||||
_engine = null;
|
||||
_loadedModelPath = null;
|
||||
await engine.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<T> _runExclusive<T>(Future<T> Function() action) {
|
||||
final completer = Completer<T>();
|
||||
_setBusy(true);
|
||||
_queue = _queue.then((_) async {
|
||||
try {
|
||||
completer.complete(await action());
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
} finally {
|
||||
_setBusy(false);
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Stream<List<int>> _trackDownloadProgress(Stream<List<int>> source) async* {
|
||||
await for (final chunk in source) {
|
||||
if (_cancelDownloadRequested) {
|
||||
throw const TranslationDownloadCancelled();
|
||||
}
|
||||
_downloadedBytes += chunk.length;
|
||||
notifyListeners();
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
void _setBusy(bool value) {
|
||||
if (_isBusy == value) {
|
||||
return;
|
||||
}
|
||||
_isBusy = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setDownloading(bool value) {
|
||||
_isDownloading = value;
|
||||
if (!value) {
|
||||
_cancelDownloadRequested = false;
|
||||
_downloadedBytes = 0;
|
||||
_downloadTotalBytes = null;
|
||||
_downloadFileName = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final engine = _engine;
|
||||
_engine = null;
|
||||
_loadedModelPath = null;
|
||||
if (engine != null) {
|
||||
unawaited(engine.dispose());
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ class UsbSerialService {
|
||||
throw StateError('USB serial port is not open');
|
||||
}
|
||||
final packet = wrapUsbSerialTxFrame(data);
|
||||
_logFrameSummary('USB TX frame', data);
|
||||
// _logFrameSummary('USB TX frame', data);
|
||||
if (_useAndroidUsbHost) {
|
||||
try {
|
||||
await _androidMethodChannel.invokeMethod<void>('write', {
|
||||
@@ -447,16 +447,16 @@ class UsbSerialService {
|
||||
await _frameController.close();
|
||||
}
|
||||
|
||||
void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||
if (bytes.isEmpty) {
|
||||
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
|
||||
return;
|
||||
}
|
||||
_debugLogService?.info(
|
||||
'$prefix code=${bytes[0]} len=${bytes.length}',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
}
|
||||
// void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||
// if (bytes.isEmpty) {
|
||||
// _debugLogService?.info('$prefix len=0', tag: 'USB Serial');
|
||||
// return;
|
||||
// }
|
||||
// _debugLogService?.info(
|
||||
// '$prefix code=${bytes[0]} len=${bytes.length}',
|
||||
// tag: 'USB Serial',
|
||||
// );
|
||||
// }
|
||||
|
||||
/// Returns an ordered list of port paths to try for [portName].
|
||||
///
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user